diff --git a/.github/workflows/format-backend.yaml b/.github/workflows/format-backend.yaml index b1b4f8db1f..6317c6ae09 100644 --- a/.github/workflows/format-backend.yaml +++ b/.github/workflows/format-backend.yaml @@ -42,10 +42,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install black + pip install "ruff>=0.15.5" - - name: Format backend - run: npm run format:backend - - - name: Check for changes after format - run: git diff --exit-code + - name: Ruff format check + run: ruff format --check . --exclude .venv --exclude venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..052336f8e7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.5 + hooks: + - id: ruff + args: [--fix, backend] + - id: ruff-format + args: [backend] diff --git a/CHANGELOG.md b/CHANGELOG.md index 250d70cc06..c1c6bb57d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,152 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.11] - 2026-03-25 + +### Added + +- ๐Ÿ”€ **Responses API streaming improvements.** The OpenAI proxy now properly handles tool call streaming and re-invocations in the Responses API, preventing duplicate tool calls and preserving output during model re-invocations. [Commit](https://github.com/open-webui/open-webui/commit/93415a48e8893139db13d02d0a6d24e8604a2ac5), [Commit](https://github.com/open-webui/open-webui/commit/f8b3a32caf00dad76687fd8fe698b86f304f3997), [Commit](https://github.com/open-webui/open-webui/commit/2ae47cf20057e92a83fd618b938f3ee9bb124e5b), [Commit](https://github.com/open-webui/open-webui/commit/adcbba34f8bbfbab3e4041269a084f2b71c076d9) +- ๐Ÿ”€ **Responses API stateful sessions.** Administrators can now enable experimental stateful session support via the ENABLE_RESPONSES_API_STATEFUL environment variable, allowing compatible backends to store responses server-side with previous_response_id anchoring for improved multi-turn conversations. [Commit](https://github.com/open-webui/open-webui/commit/dfc2dc2c0bd298cb4bfcf212ef11223586aa54f1) +- ๐Ÿ“„ **File viewing pagination.** The view_file and view_knowledge_file tools now support pagination with offset and max_chars parameters, allowing models to read large files in chunks. [Commit](https://github.com/open-webui/open-webui/commit/5d7766e1b6f7ca7749c5a5a780d7b1bb2da28a2f) +- ๐Ÿ—บ๏ธ **Knowledge search scoping.** The search_knowledge_files tool now respects model-attached knowledge, searching only within attached knowledge bases and files when available. [Commit](https://github.com/open-webui/open-webui/commit/0f0ba7dadd043460d205477fd3b57556aa970847) +- ๐Ÿ› ๏ธ **Tool HTML embed context.** Tools can now return custom context alongside HTML embeds by using a tuple format, providing the LLM with actionable information instead of a generic message. [#22691](https://github.com/open-webui/open-webui/pull/22691) +- ๐Ÿ”’ **Trusted role header configuration.** Administrators can now configure the WEBUI_AUTH_TRUSTED_ROLE_HEADER environment variable to set user roles (admin, user, or pending) via a trusted header from their identity provider or reverse proxy. [#22523](https://github.com/open-webui/open-webui/pull/22523) +- ๐Ÿ”‘ **OIDC authorization parameter injection.** Administrators can now inject extra parameters into the OIDC authorization redirect URL via the OAUTH_AUTHORIZE_PARAMS environment variable, enabling IdP pre-selection for brokers like CILogon and Keycloak. [#22863](https://github.com/open-webui/open-webui/issues/22863), [Commit](https://github.com/open-webui/open-webui/commit/69171a4c8bb7f995461b4a2feef194f112b32004) +- ๐Ÿ”‘ **Google OAuth session persistence.** Administrators can now configure Google OAuth to issue refresh tokens via the GOOGLE_OAUTH_AUTHORIZE_PARAMS environment variable, preventing OAuth sessions from expiring after one hour and ensuring tools and integrations that rely on OAuth tokens remain functional. [#22652](https://github.com/open-webui/open-webui/pull/22652) +- ๐Ÿ”Œ **Embed prompt confirmation.** Interactive tool embeds can now submit prompts to the chat without requiring same-origin access, showing a confirmation dialog for cross-origin requests to prevent abuse. [#22908](https://github.com/open-webui/open-webui/pull/22908) +- ๐Ÿฎ **Tool binary response handling.** Tool servers can now return binary data such as images, which are properly processed and displayed in chat for both multimodal and non-multimodal models. [Commit](https://github.com/open-webui/open-webui/commit/1c25b06dca83ad491b4dc3d373b1c215a7a8fd3e), [Commit](https://github.com/open-webui/open-webui/commit/108a019cb8e63a533250abe84f2b6f2b7c2131c4) +- โšก **Svelte upgrade performance.** Page and markdown rendering are now approximately 25% faster across the board, with significantly less memory usage for smoother UI interactions. [#22611](https://github.com/open-webui/open-webui/issues/22611) +- ๐Ÿงฉ **Model and filter lookup optimization.** Model and filter membership lookups are now faster thanks to optimized data structure operations during model list loading. [Commit](https://github.com/open-webui/open-webui/commit/7eae377c01f8d2de94a694b72279f769c82658cd) +- ๐Ÿ’จ **Chat render throttling.** Chat message rendering now uses requestAnimationFrame batching to stay smooth during rapid model responses, preventing dropped frames when fast models send many events per second. [#22947](https://github.com/open-webui/open-webui/pull/22947) +- ๐Ÿš€ **Function list API optimization.** The functions list API now returns only essential metadata without function source code, reducing payload sizes by over 99% and making the Functions admin page load significantly faster. [#22788](https://github.com/open-webui/open-webui/pull/22788) +- โœจ **Smoother loading animation.** The loading shimmer animation now looks smoother and more natural, with softer highlight colors. [#22516](https://github.com/open-webui/open-webui/pull/22516) +- ๐Ÿงช **Terminal connection verification.** Users can now verify their terminal server connection is working before saving the configuration, making setup more reliable. [#22567](https://github.com/open-webui/open-webui/pull/22567) +- ๐Ÿ“ **Chat folder emoji reset.** Users can now reset chat folder emojis back to the default icon using a "Reset to Default" button in the emoji picker, making it easier to revert custom icons. [#22554](https://github.com/open-webui/open-webui/pull/22554) +- ๐Ÿ“Š **Metrics export interval configuration.** Administrators can now control OpenTelemetry metrics export frequency via the OTEL_METRICS_EXPORT_INTERVAL_MILLIS environment variable, enabling cost optimization for metrics services like Grafana Cloud. [#22529](https://github.com/open-webui/open-webui/pull/22529) +- ๐Ÿฅ **Readiness probe endpoint.** A new /ready endpoint is now available for Kubernetes deployments, returning 200 only after startup completes and database/Redis are reachable, enabling more reliable container orchestration. [#22507](https://github.com/open-webui/open-webui/pull/22507) +- ๐Ÿ”ฉ **Tool server timeout configuration.** Administrators can now configure a separate HTTP timeout for tool server requests via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable, enabling fine-tuned control over how long tool calls are allowed to take. [Commit](https://github.com/open-webui/open-webui/commit/a3238aa79f344765f5b62cb64eba71ffd001abaf) +- ๐Ÿ“Œ **Knowledge file previews.** Knowledge base files can now be opened in a new tab directly from the file list, making it easier to view content without downloading. [#22629](https://github.com/open-webui/open-webui/pull/22629) +- ๐ŸŽฏ **Knowledge tool hybrid search support.** The built-in query_knowledge_files tool now respects hybrid search and reranking settings, matching the behavior of the middleware RAG pipeline. [Commit](https://github.com/open-webui/open-webui/commit/9a2c60d5954ecbc172d09e9955d52a07d135dcbc) +- ๐Ÿ—ฃ๏ธ **Temporary chat folder support.** Temporary chats can now use folder-level system prompts and knowledge files, making them more powerful for quick explorations. [Commit](https://github.com/open-webui/open-webui/commit/adcc50d3370301afd5561e0f58ff6f3ab3750818) +- ๐Ÿ“ก **Terminal port previews.** Detected ports in the File Navigator can now be previewed inline with a browser-style view, navigation controls, and an address bar, instead of only opening in a new tab. [Commit](https://github.com/open-webui/open-webui/commit/689061822173e561a153290b2bb816f4cb6f4959), [Commit](https://github.com/open-webui/open-webui/commit/1dc647f43b1929f5c4d1af393a90a47f56cb745e) +- โœ๏ธ **File renaming.** Files and folders in the File Navigator can now be renamed by double-clicking or using the context menu, with Enter to confirm and Escape to cancel. [Commit](https://github.com/open-webui/open-webui/commit/637cd136c2271baf4787815bc8bc25241626a943) +- ๐Ÿงญ **File Navigator navigation history.** The File Navigator toolbar now includes Back and Forward buttons for navigating through folder and file history, similar to a web browser. [Commit](https://github.com/open-webui/open-webui/commit/3a4b862e818c69fff6f3a3c67b50c51aa00c03e9) +- ๐Ÿ—‘๏ธ **Delete connection confirmations.** Users are now prompted with a confirmation dialog before deleting connections, preventing accidental deletions. [Commit](https://github.com/open-webui/open-webui/commit/157ff57c40bc40c53bc608828dac3779e95c2ffa) +- ๐Ÿ“ฆ **Document loader fallbacks.** Excel and PowerPoint files can now be processed even when the unstructured package is not installed, using pandas and python-pptx as fallback loaders. [Commit](https://github.com/open-webui/open-webui/commit/6862d618ee17f95d3cae78819ed993e7fbc7e632) +- ๐Ÿง  **Memory management search and sort.** Users can now search and sort their personal memories in the Memory management modal, making it easier to find specific memories. [Commit](https://github.com/open-webui/open-webui/commit/47ab4c71d50fd631b04c95f2febb085dd0a13083) +- ๐Ÿ“ฆ **SBOM generation script.** A new script for generating CycloneDX Software Bill of Materials is now available in the scripts directory. [Commit](https://github.com/open-webui/open-webui/commit/39100eca4915e4fe86a6912aa97dde86ed72e015) +- โš™๏ธ **Ruff linter and formatter.** Added Ruff as the Python linter and formatter, replacing the black-based workflow for better code quality with near-instant execution. [#22576](https://github.com/open-webui/open-webui/pull/22576), [#22462](https://github.com/open-webui/open-webui/discussions/22462) +- ๐Ÿ–ฅ๏ธ **Offline code formatting support.** The black formatter for Python code editing is now bundled locally in the Docker image, enabling code formatting to work in air-gapped deployments where client browsers cannot reach PyPI. Formatting failures no longer block saves, allowing code to be preserved even when offline. [#22509](https://github.com/open-webui/open-webui/issues/22509), [Commit](https://github.com/open-webui/open-webui/commit/8507e5eb0d18896f1bbf990a00a4361aec171a30) +- โœ๏ธ **Markdown file editing.** Users can now edit and save Markdown files directly in the file navigator, with empty files automatically switching to editor mode for immediate editing. [Commit](https://github.com/open-webui/open-webui/commit/47e47e42af682e7f75c8359999f7cdf969bf903e) +- ๐Ÿ” **Model bulk actions menu.** Users can now quickly enable, disable, show, or hide multiple models at once using a new hamburger menu on the workspace Models page filter bar, with actions respecting the current search and filter settings. [#22484](https://github.com/open-webui/open-webui/pull/22484) +- ๐Ÿ“‚ **Files list pagination.** The files list API now supports pagination, returning paginated results with a total count for easier navigation through large file collections. [Commit](https://github.com/open-webui/open-webui/commit/f9756de693a93e918c037d757afddb7defc847e4) +- ๐Ÿ–‡ **Web fetch content length config.** Administrators can now configure the maximum characters to return from fetched URLs via WEB_FETCH_MAX_CONTENT_LENGTH environment variable or the admin settings page, instead of the previous hardcoded 50K limit. [Commit](https://github.com/open-webui/open-webui/commit/b171b0216b916745420c7caf513093a315ed9560), [#22774](https://github.com/open-webui/open-webui/issues/22774) +- ๐Ÿค– **Ollama Anthropic endpoint support.** The Ollama proxy now supports the Anthropic-compatible /v1/messages endpoint, allowing clients using the Anthropic API format to work through Open WebUI with proper authentication and model access controls. [Commit](https://github.com/open-webui/open-webui/commit/f23296b22d3304e5bfcd19151e5802eec55bd98f), [#22861](https://github.com/open-webui/open-webui/issues/22861) +- ๐Ÿ“ **Writing block rendering.** Responses from OpenAI models that include :::writing blocks are now rendered as formatted content in a styled container with a copy button, instead of displaying raw marker text. [#22672](https://github.com/open-webui/open-webui/issues/22672), [Commit](https://github.com/open-webui/open-webui/commit/53b8a1f71bd0cb0a0122175ad5210da492018728) +- ๐Ÿ’ก **Memory deletion confirmation.** Users are now asked to confirm before deleting individual memory entries, with the memory content displayed for review. [#22888](https://github.com/open-webui/open-webui/pull/22888) +- ๐Ÿ““ **Multi-artifact HTML rendering.** Code blocks with multiple HTML sections now render as separate artifacts instead of merging into one, allowing models to display distinct interactive components. [Commit](https://github.com/open-webui/open-webui/commit/9a6bf78e14a13864e72db87426da4f5996abe716) +- ๐Ÿšฉ **Drag chats as references.** Users can now drag chats from the sidebar and drop them into the message input to add them as Reference Chats. [Commit](https://github.com/open-webui/open-webui/commit/ebb7ce2092efc8d78da4974623647dbd18b6e372) +- โŒจ๏ธ **Terminal system prompts.** Terminal servers can now provide custom system prompts that are automatically included when their tools are used. [Commit](https://github.com/open-webui/open-webui/commit/6a9d67b5bb4c93fd343b334bee3e37703dff59f6) +- ๐Ÿ’พ **Terminal state persistence.** The selected terminal server and its enabled state now persist across page loads, making terminal usage more seamless. [Commit](https://github.com/open-webui/open-webui/commit/d577ff1e4af750dda09e558dac7edb8dd2470850) +- ๐Ÿ’พ **Terminal folder downloads.** Users can now download folders as ZIP archives and bulk-download multiple selected files as a single ZIP directly from the File Navigator toolbar, making file exports faster and more convenient. [Commit](https://github.com/open-webui/open-webui/commit/3841e85abb3ea3e8d8b364dff0102f0124844d22), [Commit](https://github.com/open-webui/open-webui/commit/cf60b1882f1929200649b59f867289dea54e4210) +- ๐Ÿ” **MCP OAuth 2.1 static credentials.** MCP servers that require static client_id and client_secret can now be connected using a new OAuth 2.1 Static auth type, enabling integration with MCP servers that don't support dynamic client registration. [#22266](https://github.com/open-webui/open-webui/pull/22266), [Commit](https://github.com/open-webui/open-webui/commit/601bb783587a3e965cf88c148e4856b988655b13) +- ๐ŸŽช **Collapsible tool and thinking groups.** Consecutive tool calls and reasoning blocks are now grouped into a single collapsible summary (e.g., "Explored tool1, tool2"), keeping chat responses clean and readable while preserving full detail on expand. [#21604](https://github.com/open-webui/open-webui/issues/21604), [Commit](https://github.com/open-webui/open-webui/commit/261aec8c864646eb7215be0d5c14a79cad3cb93f) +- ๐Ÿ”„ **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security. +- ๐ŸŒ Translations for Finnish, Portuguese (Portugal), Catalan, Turkish, Japanese, Simplified Chinese, Traditional Chinese, Estonian, Spanish, Azerbaijani, and German were enhanced and expanded. + +### Fixed + +- ๐Ÿ”’ **Model access control bypass.** Fixed a security vulnerability where external clients could bypass model access controls by setting a URL parameter, preventing unauthorized access to restricted models. [Commit](https://github.com/open-webui/open-webui/commit/c0385f60ba049da48d2d5452068586d375303c37) +- ๐Ÿ›ก๏ธ **Terminal proxy path sanitization.** The terminal server proxy now properly sanitizes paths to prevent directory traversal and SSRF attacks, protecting against security vulnerabilities. [Commit](https://github.com/open-webui/open-webui/commit/f9d38a073fae32032ed44073cf2817cba20210bb) +- ๐Ÿ›ก๏ธ **Tool configuration access control.** Tool configuration endpoints now properly verify user permissions, preventing unauthorized access to tool settings. [Commit](https://github.com/open-webui/open-webui/commit/bc5b3ec6b8ec0fef894eb8046c636ee33688b8c4) +- ๐Ÿ—๏ธ **Tool valves access control.** The tool user valves endpoints now properly verify ownership and access grants before returning or updating configuration, with appropriate 404 responses for missing tools and 401 for unauthorized access. [Commit](https://github.com/open-webui/open-webui/commit/f949d17db1e62e0b79aecbbcbcabe3d57d8d4af6) +- ๐Ÿ” **Collaborative document authorization.** Fixed a security vulnerability in collaborative documents where authorization could be bypassed using alternative document ID formats, preventing unauthorized access to notes. [Commit](https://github.com/open-webui/open-webui/commit/3107a5363d13c899a995c930cbb1121a80f754f9) +- ๐Ÿ” **OAuth session persistence.** Users logging in via OAuth or OIDC providers now stay logged in for the configured JWT expiry duration instead of being logged out when closing the browser. [#22809](https://github.com/open-webui/open-webui/pull/22809) +- ๐Ÿšช **OAuth sub claim configuration crash.** Using the OAUTH_SUB_CLAIM environment variable no longer causes crashes during token exchange requests, fixing a missing configuration registration. [#22865](https://github.com/open-webui/open-webui/pull/22865) +- ๐Ÿ” **OAuth discovery header parsing.** The OAuth protected resource discovery now correctly handles both quoted and unquoted values in the WWW-Authenticate header, fixing compatibility with MCP servers that return unquoted metadata. [#22646](https://github.com/open-webui/open-webui/discussions/22646), [Commit](https://github.com/open-webui/open-webui/commit/fe7e002fea7283abcf901e22de5c8a7d86e336ea) +- ๐Ÿ‘ค **Admin OAuth group sync.** Admin user group memberships from OAuth and LDAP providers are now properly synced to Open WebUI, fixing a limitation where admin role excluded users from group updates. [#22537](https://github.com/open-webui/open-webui/pull/22537), [Commit](https://github.com/open-webui/open-webui/commit/a1aceb5f879abd130ef83085d98a0d51316a8fc3) +- ๐ŸŽซ **Password change complexity validation.** Password complexity rules are now properly enforced when users change their password, closing a security gap where new passwords could bypass configured complexity requirements. [Commit](https://github.com/open-webui/open-webui/commit/bd8aa3b6a0b6a2320f41b20a51b9842f39aadb7f) +- ๐Ÿ” **OAuth role enforcement.** OAuth role management now properly denies access when a user's roles don't match any configured OAUTH_ALLOWED_ROLES or OAUTH_ADMIN_ROLES, instead of silently bypassing the restriction. [#13676](https://github.com/open-webui/open-webui/issues/13676), [#15551](https://github.com/open-webui/open-webui/issues/15551), [Commit](https://github.com/open-webui/open-webui/commit/6d7744c21903ec5a9ad951770dea76e9ba19cbcc) +- ๐Ÿ”‘ **Microsoft Entra ID role claim preservation.** Role claims from Microsoft Entra ID tokens are now preserved during OAuth login, fixing ENABLE_OAUTH_ROLE_MANAGEMENT for Microsoft OAuth which was previously ignored because the userinfo endpoint stripped the roles claim. [#20518](https://github.com/open-webui/open-webui/issues/20518), [Commit](https://github.com/open-webui/open-webui/commit/aa2f7fbe5229c3985ce427602069cdeababda481) +- ๐Ÿ” **SCIM group filtering.** The SCIM endpoint now properly handles displayName and externalId filters when provisioning groups from identity providers like Microsoft Entra ID, preventing all groups from being returned instead of the filtered subset. [#21543](https://github.com/open-webui/open-webui/pull/21543) +- ๐Ÿ” **Forwarded allow IPs configuration.** The FORWARDED_ALLOW_IPS environment variable is now properly respected by the startup scripts instead of being hardcoded to '\*', allowing administrators to restrict which proxies are trusted for request forwarding. [#22539](https://github.com/open-webui/open-webui/issues/22539), [Commit](https://github.com/open-webui/open-webui/commit/0aebdd5f83cd1d811009edcbb2bec432a34e7c81) +- ๐Ÿช **Model list auth cookie forwarding.** Model list requests to backends that require cookie-based authentication now properly forward auth headers and cookies, preventing "Unauthorized" errors when loading models. [Commit](https://github.com/open-webui/open-webui/commit/76ece4049e96bd6890593f17a946a9af6b082fab) +- ๐Ÿ”ฑ **Model lookup race condition.** Fixed a race condition in Redis model storage that caused intermittent "model not found" errors in multi-replica deployments under heavy load, by eliminating the window between hash deletion and updates. [Commit](https://github.com/open-webui/open-webui/commit/ee901fcd2ca82d7a7dad48170c64df782d3e040a) +- ๐ŸŽš๏ธ **Bulk model action reliability.** Bulk enable, disable, show, and hide operations in the admin Models settings now properly refresh the model list after completion, ensuring changes are reflected immediately and correct toast notifications are shown. [#22962](https://github.com/open-webui/open-webui/pull/22962), [Commit](https://github.com/open-webui/open-webui/commit/75932be880f3b86f78f00b4352b9f1350b8f53fa), [Commit](https://github.com/open-webui/open-webui/commit/15ae3f588b1aa4ddb686ae68afebd6064036a201) +- ๐Ÿ”„ **Paginated list duplicates.** Fixed duplicate items appearing in paginated lists when loading more items in chats, knowledge, notes, and search across the UI. [Commit](https://github.com/open-webui/open-webui/commit/58e78e8946fb3644107489fe8e01b17709302b2f) +- ๐Ÿงฝ **Duplicate chat list refresh.** Sending messages no longer triggers duplicate sidebar chat list refreshes, eliminating an unnecessary database query that was already handled by the save and completion handlers. [#22982](https://github.com/open-webui/open-webui/pull/22982) +- ๐Ÿงน **Chat history save optimization.** The chat list is no longer refreshed on every chat history save, branch navigation, or edit โ€” only on meaningful state changes like new chat creation, title generation, and response completion. [#22983](https://github.com/open-webui/open-webui/pull/22983) +- ๐Ÿ’ฌ **Message queue responsiveness.** The message queue no longer waits for background tasks like title generation and follow-up suggestions to complete, allowing users to send new messages immediately after a response finishes without unnecessary delays. [Commit](https://github.com/open-webui/open-webui/commit/486c004cbb43f15d5c3e31561f51f22effff1f6c), [#22565](https://github.com/open-webui/open-webui/issues/22565) +- ๐Ÿ—„๏ธ **Migration reliability.** Database migrations no longer fail when chat data has unexpected format, making upgrades more reliable. [#22588](https://github.com/open-webui/open-webui/pull/22588), [#22568](https://github.com/open-webui/open-webui/issues/22568) +- ๐Ÿซง **Memory modal event bubbling.** Fixed an issue where clicking the Delete button in the Memory management modal would also open the Edit Memory modal due to event bubbling. [#22783](https://github.com/open-webui/open-webui/issues/22783) +- ๐Ÿงฉ **Memory tool registration.** Models with capabilities.memory: true now correctly have memory tools available for execution, fixing a retry loop where add_memory appeared in the tool schema but was not registered for backend execution. [#22666](https://github.com/open-webui/open-webui/issues/22666), [#22675](https://github.com/open-webui/open-webui/pull/22675), [Commit](https://github.com/open-webui/open-webui/commit/d9339919046c3e977f313f603782d220aab4257f) +- ๐Ÿ“ **Input variables modal crash.** Fixed a crash that occurred when selecting custom prompts with prompt variables, causing the Input Variables modal to display an infinite loading spinner instead of the variable input fields. [#22748](https://github.com/open-webui/open-webui/issues/22748), [Commit](https://github.com/open-webui/open-webui/commit/0dcd6ac983bede06b8477179192154467f5b24a2) +- ๐Ÿช› **Function list API crash fix.** Fixed a 500 error on the functions list API endpoint that was introduced by the recent optimization, by adding proper model configuration for SQLAlchemy ORM objects. [#22924](https://github.com/open-webui/open-webui/pull/22924) +- ๐Ÿ—‚๏ธ **Sidebar chat menu closure.** Sidebar chat dropdown menus now close properly after clicking "Clone", "Share", "Download", "Rename", "Pin", "Move", "Archive", or "Delete", instead of remaining visible. [#22884](https://github.com/open-webui/open-webui/pull/22884), [#22784](https://github.com/open-webui/open-webui/issues/22784) +- ๐Ÿงญ **Chat deletion and archive redirection.** Users are now redirected to the chat list when deleting or archiving the currently active chat, instead of being left on a stale chat page. [#22755](https://github.com/open-webui/open-webui/pull/22755) +- ๐Ÿšฉ **User menu navigation fix.** Clicking Playground or Admin Panel from the user menu now uses client-side routing instead of causing full page reloads, restoring smooth SPA navigation. [Commit](https://github.com/open-webui/open-webui/commit/7ffcd3908ee90f88a4c4684d6cd6e75efd117461) +- ๐Ÿ”ง **Tool server connection persistence.** Fixed a bug where tool server connection updates were not being saved to persistent storage, ensuring OAuth client information is now properly preserved. [Commit](https://github.com/open-webui/open-webui/commit/b8ea267f8ec3931de55db7801156b9c07d3ad5f6) +- ๐Ÿ”ฉ **Tool server index bounds checking.** Tool servers with invalid indices no longer crash the application with IndexError after upgrades, preventing tool server configuration loss. [#22490](https://github.com/open-webui/open-webui/issues/22490), [Commit](https://github.com/open-webui/open-webui/commit/8da29566a1f81c38e80009bdea3ce4d9be860605) +- ๐Ÿ”Œ **Tool server frontend timeout.** Fetch requests to external tool servers now time out after 10 seconds, preventing the UI from hanging indefinitely when a configured tool server is unreachable. [#22543](https://github.com/open-webui/open-webui/issues/22543), [Commit](https://github.com/open-webui/open-webui/commit/adf7af34ff934319a35470c572237d2d08f1de0b) +- ๐Ÿ”Œ **MCP OAuth tool auto-selection.** MCP tools requiring OAuth authentication are now automatically re-selected after completing the auth flow, instead of leaving users to manually re-enable the tool on return to the chat. [#22994](https://github.com/open-webui/open-webui/issues/22994), [#22995](https://github.com/open-webui/open-webui/pull/22995), [Commit](https://github.com/open-webui/open-webui/commit/4d50001c4192c609b1010626ebb6496692823873) +- ๐Ÿท๏ธ **Channel @mentions.** Direct connection models no longer appear in channel @mention suggestions, preventing confusion since they don't work in channels. [#22553](https://github.com/open-webui/open-webui/issues/22553), [Commit](https://github.com/open-webui/open-webui/commit/0a87c1ecd078320a08c4cc62d41fe8727fb3b5f7) +- ๐Ÿ“Ž **Channel message attachments.** Users can now press Enter to send messages with only file or image attachments in channels, direct messages, and threads, aligning with the behavior of the Send button. [#22752](https://github.com/open-webui/open-webui/pull/22752) +- ๐Ÿ—ฃ๏ธ **Image-only message handling.** Models like Gemini and Claude no longer fail when receiving messages with only file or image attachments and no text, by stripping empty text content blocks before sending to the API. [Commit](https://github.com/open-webui/open-webui/commit/ea515fa26e11faac146c48a5e3a2a284e1792bb3), [#22880](https://github.com/open-webui/open-webui/issues/22880) +- ๐Ÿงน **Channel thread sidebar cleanup.** The thread sidebar in channels and direct messages now automatically closes when the parent message is deleted, preventing orphaned threads. [#22890](https://github.com/open-webui/open-webui/pull/22890) +- ๐Ÿ’ก **Chat input suggestion modal.** The suggestion modal for tags, mentions, and commands now correctly reappears when backspacing into a trigger character after it was dismissed. [#22899](https://github.com/open-webui/open-webui/pull/22899) +- โฑ๏ธ **Chat action button timing.** Action buttons under assistant messages no longer appear prematurely when switching chats while a response is still streaming. [Commit](https://github.com/open-webui/open-webui/commit/ecba37070d6eb3cb033195a070b6c4ab5f396415), [#22891](https://github.com/open-webui/open-webui/issues/22891) +- ๐Ÿ’ฌ **Skill and model mention persistence.** Skills selected via $ and models selected via @ in the chat input are now properly restored after a page refresh, instead of reverting to plain text while losing their interactive state. [#22913](https://github.com/open-webui/open-webui/issues/22913), [Commit](https://github.com/open-webui/open-webui/commit/be21db706993c0db95ac09509dfdb023de64daff) +- ๐Ÿงน **Webhook profile image errors.** Fixed 404 errors appearing in the browser console when scrolling through channel messages sent by webhooks, by skipping the user profile preview for webhook senders. [#22893](https://github.com/open-webui/open-webui/pull/22893) +- ๐Ÿงฎ **Logit bias parameter handling.** Using logit_bias parameters no longer causes errors when the input is already in dictionary format. [#22597](https://github.com/open-webui/open-webui/issues/22597), [Commit](https://github.com/open-webui/open-webui/commit/e34ed72e1e958505e940b74bf1c6a4808640bd17) +- ๐Ÿช› **Temp chat tool calling.** Temporary chats now properly preserve tool call information, fixing native tool calling with JSON schema that was previously broken. [#22475](https://github.com/open-webui/open-webui/pull/22475), [Commit](https://github.com/open-webui/open-webui/commit/bcd313c363ca50d71aa80bcb2f29c81fad3dff37) +- ๐Ÿ”— **Multi-system message merging.** Models with strict chat templates like Qwen no longer fail when multiple pipeline stages inject separate system messages, as all system messages are now merged into one at the start. [#22505](https://github.com/open-webui/open-webui/issues/22505), [Commit](https://github.com/open-webui/open-webui/commit/631bd20c3537ce85bbaec02f9e0049c88fa8fdd4) +- ๐Ÿ“œ **Public note access.** Opening public notes via direct share link no longer returns a 500 error caused by a missing function import. [#22680](https://github.com/open-webui/open-webui/issues/22680), [Commit](https://github.com/open-webui/open-webui/commit/566e25569e5e7d9c1e42db840ba4ba578887d208) +- ๐Ÿ‘ค **Terminal access user visibility.** The terminal connection access dialog now shows the currently logged-in user when searching for users to grant access, fixing an issue where users with identical display names were filtered incorrectly. [#22491](https://github.com/open-webui/open-webui/issues/22491), [Commit](https://github.com/open-webui/open-webui/commit/4a8f995c3fd4602ec2aaccc07efc4e8504dda84d) +- ๐Ÿ‘ฅ **User groups display.** User groups in the admin panel profile preview now wrap properly instead of overflowing horizontally, with a scrollbar when the list is long. [#22547](https://github.com/open-webui/open-webui/pull/22547) +- ๐Ÿ”ง **Model list drag-and-drop.** Fixed drag-and-drop reordering of models in admin settings, preventing UI glitches and state synchronization issues. [Commit](https://github.com/open-webui/open-webui/commit/753589e51ccbbe5c4f78a7d13e19c67e6c0000d7) +- ๐Ÿ–ผ๏ธ **Model profile image fallbacks.** Model profile images now display a fallback icon when they fail to load, and model icons no longer disappear on paginated Models pages in admin and workspace settings. [#22485](https://github.com/open-webui/open-webui/pull/22485) +- ๐Ÿ–ผ๏ธ **Profile image fallbacks.** Added fallback handlers for model and user profile images throughout the chat interface, preventing broken image icons when avatars fail to load. [#22486](https://github.com/open-webui/open-webui/pull/22486) +- ๐Ÿงฒ **RAG thinking model support.** Knowledge base queries now correctly parse JSON responses from thinking models like GLM-5 and DeepSeek-R1 by stripping their reasoning blocks before JSON extraction. [#22400](https://github.com/open-webui/open-webui/pull/22400) +- ๐Ÿ” **RAG query generation robustness.** The RAG query generation, web search, and image generation handlers now correctly extract JSON from model responses containing thinking tags by finding the last JSON block instead of the first, preventing "No sources found" errors with thinking models. [#21888](https://github.com/open-webui/open-webui/issues/21888), [Commit](https://github.com/open-webui/open-webui/commit/c0fcbc5b4cb29012e2913983c632edc5d24b9aea) +- ๐Ÿ” **Ollama embedding robustness.** Ollama embedding requests now include the truncate parameter to handle inputs exceeding the context window, preventing 500 errors when processing long documents. Error messages from failed embedding requests are also now properly surfaced instead of being silently swallowed. [#22671](https://github.com/open-webui/open-webui/issues/22671), [Commit](https://github.com/open-webui/open-webui/commit/d738044f47c70c755bec9bf244aa11878fe98d9c) +- ๐Ÿ”„ **Ollama embedding retry logic.** Embedding requests to Ollama now retry with exponential backoff when encountering 503 errors (such as when the model reloads mid-processing), preventing files from being silently dropped from knowledge bases. [#22571](https://github.com/open-webui/open-webui/issues/22571), [Commit](https://github.com/open-webui/open-webui/commit/8b6fa1f4ab6099a305de08706621075c205f65c4) +- ๐Ÿ—„๏ธ **Oracle 23AI hybrid search.** Fixed an UnboundLocalError that occurred when using hybrid search with Oracle 23AI as the vector store, preventing knowledge base queries from failing. [Commit](https://github.com/open-webui/open-webui/commit/fcf720835285a4cea10fc1ebed0b454971463b20), [#22616](https://github.com/open-webui/open-webui/issues/22616) +- ๐ŸŒ **Dynamic HTML language attribute.** The HTML lang attribute now dynamically updates when users change their interface language, preventing browsers from triggering unwanted translation popups. [Commit](https://github.com/open-webui/open-webui/commit/de5e0fbc00e7abcd84e1272c301b0707f8ea5ac6) +- ๐Ÿ“ **File upload deduplication.** Attaching files that are already in the chat no longer triggers duplicate uploads. [Commit](https://github.com/open-webui/open-webui/commit/10f06a64fed474e9958b96295a953e0eebf9e4be) +- ๐Ÿ•ต๏ธ **Serper.dev search results.** Fixed web search results not displaying properly when using the Serper.dev provider by using the correct API response field. [#22869](https://github.com/open-webui/open-webui/pull/22869) +- ๐Ÿ”ฒ **Markdown task list checkbox styling.** Fixed task list checkboxes in markdown rendering to display consistently without shrinking in narrow layouts. [#22886](https://github.com/open-webui/open-webui/pull/22886) +- ๐ŸŽจ **Artifacts sidebar tab background fix.** The Artifacts sidebar now correctly updates and displays when switching back to a browser tab that was in the background, ensuring artifacts are visible without requiring a manual refresh. [#22889](https://github.com/open-webui/open-webui/issues/22889) +- ๐Ÿ”ƒ **Chat input URL indexing fix.** Fixed an issue where URLs could be indexed twice when using multiple triggers followed by backspace and re-entering a URL. [#22749](https://github.com/open-webui/open-webui/issues/22749) +- ๐Ÿ”Ž **Search modal chat preview avatars.** Fixed assistant profile images not displaying in the chat preview pane of the Search Modal. [#22782](https://github.com/open-webui/open-webui/pull/22782) +- ๐Ÿ“‹ **Prompts search pagination fix.** Fixed a bug where searching prompts from a paginated page would incorrectly use the current page number, resulting in "No prompts found" even when matching results existed. [#22912](https://github.com/open-webui/open-webui/pull/22912) +- ๐Ÿ—‚๏ธ **Reasoning block copy cleanup.** Copied chat responses no longer include reasoning block content or excess whitespace, ensuring only the intended message text is captured. [#22786](https://github.com/open-webui/open-webui/issues/22786), [Commit](https://github.com/open-webui/open-webui/commit/4f0e57420154800946394bc986b2c691462b2782) +- ๐Ÿ”ค **Emoji removal for text normalization.** Fixed the emoji removal function used in search and title generation to correctly handle all emoji types, including those with variation selectors (โค๏ธ, โ˜€๏ธ, โœ…), keycap sequences (1๏ธโƒฃ), and ZWJ family sequences (๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ). [#22915](https://github.com/open-webui/open-webui/pull/22915) +- โน๏ธ **Task cancellation status tracking.** Cancelled tasks now correctly mark only the affected messages as done instead of clearing all task statuses for the chat, ensuring proper status tracking when multiple messages have pending tasks. [#22743](https://github.com/open-webui/open-webui/pull/22743) +- ๐ŸŽจ **Filter icon display fix.** Fixed filter icons showing the wrong icon after removing one of multiple active filters below the chat input. [#22862](https://github.com/open-webui/open-webui/pull/22862) +- ๐Ÿ“Š **Channel message data loading.** Fixed redundant 404 API calls that occurred when rendering channel messages, preventing unnecessary requests and console errors. [#22894](https://github.com/open-webui/open-webui/pull/22894) +- ๐Ÿ‘ป **Response message skeleton display.** Fixed an issue where the skeleton loader would incorrectly show or hide based on complex status history conditions, by extracting the visibility logic into a cleaner reactive variable. [Commit](https://github.com/open-webui/open-webui/commit/5df4277216fbb9de603fdf4289f8366292568234) +- ๐Ÿ› **Shared chat viewing crash.** Shared chats can now be viewed by unauthenticated users without crashing, with proper fallback handling for missing user profile information. [#22751](https://github.com/open-webui/open-webui/pull/22751), [#22742](https://github.com/open-webui/open-webui/issues/22742) +- ๐Ÿ› ๏ธ **Plugin ID sanitization.** Creating Functions or Tools with emojis or special characters in their names now generates valid IDs that pass backend validation, instead of failing with an error. [#22695](https://github.com/open-webui/open-webui/pull/22695) +- ๐Ÿ“‹ **Chat title preservation.** Regenerating responses or using branches no longer overwrites user-specified chat titles when auto-naming is disabled, by checking the full chat message count instead of just the current branch. [#22754](https://github.com/open-webui/open-webui/pull/22754) +- ๐ŸŽง **Read Aloud in chat preview.** The Read Aloud button in the Search Chats modal preview no longer causes crashes, and TTS functionality is now properly hidden in read-only chat contexts. [Commit](https://github.com/open-webui/open-webui/commit/d8fa0f426a88f5c27b3216b7db35e1db47bbba28) +- ๐Ÿ“ก **Heartbeat event loop blocking.** The WebSocket heartbeat handler no longer blocks the event loop when updating user activity, improving responsiveness under heavy load with many concurrent connections. [#22980](https://github.com/open-webui/open-webui/pull/22980) +- ๐Ÿ—๏ธ **Message upsert API reliability.** The message upsert API endpoint no longer crashes when called, fixing an error where a database session was incorrectly passed to a function that doesn't accept it. [#22959](https://github.com/open-webui/open-webui/issues/22959), [Commit](https://github.com/open-webui/open-webui/commit/70285fb6cad26b50d783583b68be5227ace16055) +- ๐Ÿ”“ **Forward auth proxy compatibility.** Fixed error pages that could appear when using authenticating reverse-proxies by properly handling 401 responses from background API requests, allowing the browser to re-authenticate with the identity provider. [#22942](https://github.com/open-webui/open-webui/pull/22942) +- ๐Ÿ”ƒ **Tool call streaming display.** Sequential tool calls are now properly accumulated during streaming, fixing an issue where completed tool calls could disappear from the display before the next tool call finished streaming. [Commit](https://github.com/open-webui/open-webui/commit/a9c5c787b9f6b10491924d38645042064b3c941e) +- ๐Ÿง  **Reasoning spinner content preservation.** Prior assistant content and tool call blocks no longer disappear during the reasoning spinner when responding after tool execution. [#23001](https://github.com/open-webui/open-webui/pull/23001) +- ๐Ÿ–ฅ๏ธ **Pyodide file list refresh.** Files created or modified during manual code execution now appear immediately in the pyodide files list without requiring a browser tab refresh. [Commit](https://github.com/open-webui/open-webui/commit/5c4062c64841974bf193ff321d92d10f28a09746) +- ๐Ÿ–ฑ๏ธ **Dropdown submenu hover stability.** Secondary hover menus like Download and Move now remain open while navigating into them, fixing an issue where an 8px gap between the trigger and submenu would cause the menu to disappear before a selection could be made. [#22744](https://github.com/open-webui/open-webui/issues/22744), [Commit](https://github.com/open-webui/open-webui/commit/cffbc3558e911abd6c4780cd028794b2f7282cd7) +- ๐Ÿ“Š **Model tag normalization.** Model tags from backends that return them as string arrays are now properly normalized to object format, preventing crashes when filtering models by tag in the admin and workspace models pages. [#20819](https://github.com/open-webui/open-webui/issues/20819), [Commit](https://github.com/open-webui/open-webui/commit/90ca2e9b0f15cc9be7cf298fbefacaa45074cae9) +- ๐ŸŽฏ **Arena model sub-model settings.** Arena models now properly use the selected sub-model's settings โ€” including RAG knowledge bases, web access, code interpreter, and tool capabilities โ€” instead of the arena wrapper's empty defaults. [#16950](https://github.com/open-webui/open-webui/issues/16950), [Commit](https://github.com/open-webui/open-webui/commit/857d7e6f373d26a7a8989417c3a7fe99cdc03f20) +- ๐Ÿงฉ **Model editor default metadata.** The Model Editor now loads admin-configured default model metadata instead of hardcoded values, preventing admin defaults from being silently overwritten when users save models without realizing they were overriding system-wide settings. [#22996](https://github.com/open-webui/open-webui/issues/22996), [Commit](https://github.com/open-webui/open-webui/commit/cdc2b3bf850044051aafcd46f22fb25a1899788c) +- โœ๏ธ **Rich text paste sanitization.** Copying and pasting text with HTML characters (like `<` or `>`) no longer corrupts the editor content, as the paste handler now properly escapes HTML entities before processing mentions and special syntax. [Commit](https://github.com/open-webui/open-webui/commit/94f877ff328d410339308ad2c566c9afcdf43014) + +### Changed + +- ๐Ÿช **User webhooks disabled by default.** User webhook notifications are now disabled by default and properly gated by the ENABLE_USER_WEBHOOKS configuration, ensuring webhooks only fire when explicitly enabled. [Commit](https://github.com/open-webui/open-webui/commit/c24a4da17dbaddf47e2e0f865c1d602d0ff36ee6) +- ๐Ÿงฉ **MCP integration visibility.** MCP (Streamable HTTP) integrations are now hidden from user-level settings, matching the intended behavior where only administrators can configure MCP connections through the admin panel. User-level connections now show the connection type as read-only. [#22615](https://github.com/open-webui/open-webui/issues/22615), [Commit](https://github.com/open-webui/open-webui/commit/1eef5b4f6a718c0fcf3605f1ed62669aca07b454) +- ๐Ÿงฒ **Web search result limit.** The configured web search result count now acts as a maximum limit, preventing models from requesting more results than administrators allow. [#22577](https://github.com/open-webui/open-webui/pull/22577) + ## [0.8.10] - 2026-03-08 ### Added @@ -3972,7 +4118,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **๐Ÿ”— Built-in LiteLLM Proxy**: Now includes LiteLLM proxy within Open WebUI for enhanced functionality. - - Easily integrate existing LiteLLM configurations using `-v /path/to/config.yaml:/app/backend/data/litellm/config.yaml` flag. - When utilizing Docker container to run Open WebUI, ensure connections to localhost use `host.docker.internal`. diff --git a/CHANGELOG_EXTRA.md b/CHANGELOG_EXTRA.md index b90fa771f4..adeb272bfc 100644 --- a/CHANGELOG_EXTRA.md +++ b/CHANGELOG_EXTRA.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.11.1] - 2026.03.26 + +### Changed + +- ๅˆๅนถๅฎ˜ๆ–น 0.8.11 ๆ”นๅŠจ + ## [0.8.10.2] - 2026.03.18 ### Added diff --git a/Dockerfile b/Dockerfile index bb9a767fc6..e44063a1f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -136,29 +136,30 @@ RUN apt-get update && \ # install python dependencies COPY --chown=$UID:$GID ./backend/requirements.txt ./requirements.txt -RUN pip3 install --no-cache-dir uv && \ +RUN set -e; \ + pip3 install --no-cache-dir uv; \ if [ "$USE_CUDA" = "true" ]; then \ # If you use CUDA the whisper and embedding model will be downloaded on first use # fix: pin torch<=2.9.1 - torch 2.10.0 aarch64 wheels cause SIGILL on ARM devices (RPi 4 Cortex-A72) #21349 - pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir && \ - uv pip install --system -r requirements.txt --no-cache-dir && \ - python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ - python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \ + pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/$USE_CUDA_DOCKER_VER --no-cache-dir; \ + uv pip install --system -r requirements.txt --no-cache-dir; \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')"; \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ python -c "import nltk; nltk.download('punkt_tab')"; \ else \ - pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir && \ - uv pip install --system -r requirements.txt --no-cache-dir && \ + pip3 install 'torch<=2.9.1' torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --no-cache-dir; \ + uv pip install --system -r requirements.txt --no-cache-dir; \ if [ "$USE_SLIM" != "true" ]; then \ - python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')" && \ - python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')" && \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ['RAG_EMBEDDING_MODEL'], device='cpu')"; \ + python -c "import os; from sentence_transformers import SentenceTransformer; SentenceTransformer(os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2'), device='cpu')"; \ python -c "import os; from faster_whisper import WhisperModel; WhisperModel(os.environ['WHISPER_MODEL'], device='cpu', compute_type='int8', download_root=os.environ['WHISPER_MODEL_DIR'])"; \ python -c "import os; import tiktoken; tiktoken.get_encoding(os.environ['TIKTOKEN_ENCODING_NAME'])"; \ python -c "import nltk; nltk.download('punkt_tab')"; \ fi; \ fi; \ - mkdir -p /app/backend/data && chown -R $UID:$GID /app/backend/data/ && \ + mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \ rm -rf /var/lib/apt/lists/*; # Install Ollama if requested diff --git a/backend/dev.sh b/backend/dev.sh index 042fbd9efa..838b93f653 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -1,3 +1,3 @@ export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080" PORT="${PORT:-8080}" -uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload +uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" --reload diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index 967a49de8f..be25227d36 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -2,102 +2,95 @@ import os import random from pathlib import Path +from typing import Annotated import typer import uvicorn -from typing import Optional -from typing_extensions import Annotated app = typer.Typer() -KEY_FILE = Path.cwd() / ".webui_secret_key" +KEY_FILE = Path.cwd() / '.webui_secret_key' -def version_callback(value: bool): +def version_callback(value: bool) -> None: if value: from open_webui.env import VERSION - typer.echo(f"Open WebUI version: {VERSION}") + typer.echo(f'Open WebUI version: {VERSION}') raise typer.Exit() @app.command() def main( - version: Annotated[ - Optional[bool], typer.Option("--version", callback=version_callback) - ] = None, + version: Annotated[bool | None, typer.Option('--version', callback=version_callback)] = None, ): pass @app.command() def serve( - host: str = "0.0.0.0", + host: str = '0.0.0.0', port: int = 8080, ): - os.environ["FROM_INIT_PY"] = "true" - if os.getenv("WEBUI_SECRET_KEY") is None: - typer.echo( - "Loading WEBUI_SECRET_KEY from file, not provided as an environment variable." - ) + os.environ['FROM_INIT_PY'] = 'true' + if os.getenv('WEBUI_SECRET_KEY') is None: + typer.echo('Loading WEBUI_SECRET_KEY from file, not provided as an environment variable.') if not KEY_FILE.exists(): - typer.echo(f"Generating a new secret key and saving it to {KEY_FILE}") + typer.echo(f'Generating a new secret key and saving it to {KEY_FILE}') KEY_FILE.write_bytes(base64.b64encode(random.randbytes(12))) - typer.echo(f"Loading WEBUI_SECRET_KEY from {KEY_FILE}") - os.environ["WEBUI_SECRET_KEY"] = KEY_FILE.read_text() + typer.echo(f'Loading WEBUI_SECRET_KEY from {KEY_FILE}') + os.environ['WEBUI_SECRET_KEY'] = KEY_FILE.read_text() - if os.getenv("USE_CUDA_DOCKER", "false") == "true": - typer.echo( - "CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries." - ) - LD_LIBRARY_PATH = os.getenv("LD_LIBRARY_PATH", "").split(":") - os.environ["LD_LIBRARY_PATH"] = ":".join( + if os.getenv('USE_CUDA_DOCKER', 'false') == 'true': + typer.echo('CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries.') + LD_LIBRARY_PATH = os.getenv('LD_LIBRARY_PATH', '').split(':') + os.environ['LD_LIBRARY_PATH'] = ':'.join( LD_LIBRARY_PATH + [ - "/usr/local/lib/python3.11/site-packages/torch/lib", - "/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib", + '/usr/local/lib/python3.11/site-packages/torch/lib', + '/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib', ] ) try: import torch - assert torch.cuda.is_available(), "CUDA not available" - typer.echo("CUDA seems to be working") + assert torch.cuda.is_available(), 'CUDA not available' + typer.echo('CUDA seems to be working') except Exception as e: typer.echo( - "Error when testing CUDA but USE_CUDA_DOCKER is true. " - "Resetting USE_CUDA_DOCKER to false and removing " - f"LD_LIBRARY_PATH modifications: {e}" + 'Error when testing CUDA but USE_CUDA_DOCKER is true. ' + 'Resetting USE_CUDA_DOCKER to false and removing ' + f'LD_LIBRARY_PATH modifications: {e}' ) - os.environ["USE_CUDA_DOCKER"] = "false" - os.environ["LD_LIBRARY_PATH"] = ":".join(LD_LIBRARY_PATH) + os.environ['USE_CUDA_DOCKER'] = 'false' + os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH) - import open_webui.main # we need set environment variables before importing main + import open_webui.main # noqa: F401 from open_webui.env import UVICORN_WORKERS # Import the workers setting uvicorn.run( - "open_webui.main:app", + 'open_webui.main:app', host=host, port=port, - forwarded_allow_ips="*", + forwarded_allow_ips='*', workers=UVICORN_WORKERS, ) @app.command() def dev( - host: str = "0.0.0.0", + host: str = '0.0.0.0', port: int = 8080, reload: bool = True, ): uvicorn.run( - "open_webui.main:app", + 'open_webui.main:app', host=host, port=port, reload=reload, - forwarded_allow_ips="*", + forwarded_allow_ips='*', ) -if __name__ == "__main__": +if __name__ == '__main__': app() diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index cbb782fc1d..76e317dfd1 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -42,11 +42,11 @@ class EndpointFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: - return record.getMessage().find("/health") == -1 + return record.getMessage().find('/health') == -1 # Filter out /endpoint -logging.getLogger("uvicorn.access").addFilter(EndpointFilter()) +logging.getLogger('uvicorn.access').addFilter(EndpointFilter()) #################################### @@ -55,7 +55,7 @@ def filter(self, record: logging.LogRecord) -> bool: class Config(Base): - __tablename__ = "config" + __tablename__ = 'config' id = Column(Integer, primary_key=True) data = Column(JSON, nullable=False) @@ -65,7 +65,7 @@ class Config(Base): def load_json_config(): - with open(f"{DATA_DIR}/config.json", "r") as file: + with open(f'{DATA_DIR}/config.json', 'r') as file: return json.load(file) @@ -89,14 +89,14 @@ def reset_config(): # When initializing, check if config.json exists and migrate it to the database -if os.path.exists(f"{DATA_DIR}/config.json"): +if os.path.exists(f'{DATA_DIR}/config.json'): data = load_json_config() save_to_db(data) - os.rename(f"{DATA_DIR}/config.json", f"{DATA_DIR}/old_config.json") + os.rename(f'{DATA_DIR}/config.json', f'{DATA_DIR}/old_config.json') DEFAULT_CONFIG = { - "version": 0, - "ui": {}, + 'version': 0, + 'ui': {}, } @@ -110,7 +110,7 @@ def get_config(): def get_config_value(config_path: str): - path_parts = config_path.split(".") + path_parts = config_path.split('.') cur_config = CONFIG_DATA for key in path_parts: if key in cur_config: @@ -139,11 +139,9 @@ def save_config(config): return True -T = TypeVar("T") +T = TypeVar('T') -ENABLE_PERSISTENT_CONFIG = ( - os.environ.get("ENABLE_PERSISTENT_CONFIG", "True").lower() == "true" -) +ENABLE_PERSISTENT_CONFIG = os.environ.get('ENABLE_PERSISTENT_CONFIG', 'True').lower() == 'true' class PersistentConfig(Generic[T]): @@ -154,13 +152,8 @@ def __init__(self, env_name: str, config_path: str, env_value: T): self.config_value = get_config_value(config_path) if self.config_value is not None and ENABLE_PERSISTENT_CONFIG: - if ( - self.config_path.startswith("oauth.") - and not ENABLE_OAUTH_PERSISTENT_CONFIG - ): - log.info( - f"Skipping loading of '{env_name}' as OAuth persistent config is disabled" - ) + if self.config_path.startswith('oauth.') and not ENABLE_OAUTH_PERSISTENT_CONFIG: + log.info(f"Skipping loading of '{env_name}' as OAuth persistent config is disabled") self.value = env_value else: log.info(f"'{env_name}' loaded from the latest database entry") @@ -175,26 +168,22 @@ def __str__(self): @property def __dict__(self): - raise TypeError( - "PersistentConfig object cannot be converted to dict, use config_get or .value instead." - ) + raise TypeError('PersistentConfig object cannot be converted to dict, use config_get or .value instead.') def __getattribute__(self, item): - if item == "__dict__": - raise TypeError( - "PersistentConfig object cannot be converted to dict, use config_get or .value instead." - ) + if item == '__dict__': + raise TypeError('PersistentConfig object cannot be converted to dict, use config_get or .value instead.') return super().__getattribute__(item) def update(self): new_value = get_config_value(self.config_path) if new_value is not None: self.value = new_value - log.info(f"Updated {self.env_name} to new value {self.value}") + log.info(f'Updated {self.env_name} to new value {self.value}') def save(self): log.info(f"Saving '{self.env_name}' to the database") - path_parts = self.config_path.split(".") + path_parts = self.config_path.split('.') sub_config = CONFIG_DATA for key in path_parts[:-1]: if key not in sub_config: @@ -216,12 +205,12 @@ def __init__( redis_url: Optional[str] = None, redis_sentinels: Optional[list] = [], redis_cluster: Optional[bool] = False, - redis_key_prefix: str = "open-webui", + redis_key_prefix: str = 'open-webui', ): if redis_url: - super().__setattr__("_redis_key_prefix", redis_key_prefix) + super().__setattr__('_redis_key_prefix', redis_key_prefix) super().__setattr__( - "_redis", + '_redis', get_redis_connection( redis_url, redis_sentinels, @@ -230,7 +219,7 @@ def __init__( ), ) - super().__setattr__("_state", {}) + super().__setattr__('_state', {}) def __setattr__(self, key, value): if isinstance(value, PersistentConfig): @@ -240,7 +229,7 @@ def __setattr__(self, key, value): self._state[key].save() if self._redis and ENABLE_PERSISTENT_CONFIG: - redis_key = f"{self._redis_key_prefix}:config:{key}" + redis_key = f'{self._redis_key_prefix}:config:{key}' self._redis.set(redis_key, json.dumps(self._state[key].value)) def __getattr__(self, key): @@ -249,7 +238,7 @@ def __getattr__(self, key): # If Redis is available and persistent config is enabled, check for an updated value if self._redis and ENABLE_PERSISTENT_CONFIG: - redis_key = f"{self._redis_key_prefix}:config:{key}" + redis_key = f'{self._redis_key_prefix}:config:{key}' redis_value = self._redis.get(redis_key) if redis_value is not None: @@ -259,10 +248,10 @@ def __getattr__(self, key): # Update the in-memory value if different if self._state[key].value != decoded_value: self._state[key].value = decoded_value - log.info(f"Updated {key} from Redis: {decoded_value}") + log.info(f'Updated {key} from Redis: {decoded_value}') except json.JSONDecodeError: - log.error(f"Invalid JSON format in Redis for {key}: {redis_value}") + log.error(f'Invalid JSON format in Redis for {key}: {redis_value}') return self._state[key].value @@ -272,388 +261,388 @@ def __getattr__(self, key): #################################### ENABLE_API_KEYS = PersistentConfig( - "ENABLE_API_KEYS", - "auth.enable_api_keys", - os.environ.get("ENABLE_API_KEYS", "False").lower() == "true", + 'ENABLE_API_KEYS', + 'auth.enable_api_keys', + os.environ.get('ENABLE_API_KEYS', 'False').lower() == 'true', ) ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = PersistentConfig( - "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS", - "auth.api_key.endpoint_restrictions", + 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS', + 'auth.api_key.endpoint_restrictions', os.environ.get( - "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS", - os.environ.get("ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "False"), + 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS', + os.environ.get('ENABLE_API_KEY_ENDPOINT_RESTRICTIONS', 'False'), ).lower() - == "true", + == 'true', ) API_KEYS_ALLOWED_ENDPOINTS = PersistentConfig( - "API_KEYS_ALLOWED_ENDPOINTS", - "auth.api_key.allowed_endpoints", - os.environ.get( - "API_KEYS_ALLOWED_ENDPOINTS", os.environ.get("API_KEY_ALLOWED_ENDPOINTS", "") - ), + 'API_KEYS_ALLOWED_ENDPOINTS', + 'auth.api_key.allowed_endpoints', + os.environ.get('API_KEYS_ALLOWED_ENDPOINTS', os.environ.get('API_KEY_ALLOWED_ENDPOINTS', '')), ) -JWT_EXPIRES_IN = PersistentConfig( - "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "4w") -) +JWT_EXPIRES_IN = PersistentConfig('JWT_EXPIRES_IN', 'auth.jwt_expiry', os.environ.get('JWT_EXPIRES_IN', '4w')) -if JWT_EXPIRES_IN.value == "-1": +if JWT_EXPIRES_IN.value == '-1': log.warning( "โš ๏ธ SECURITY WARNING: JWT_EXPIRES_IN is set to '-1'\n" - " See: https://docs.openwebui.com/reference/env-configuration\n" + ' See: https://docs.openwebui.com/reference/env-configuration\n' ) #################################### # OAuth config #################################### -ENABLE_OAUTH_PERSISTENT_CONFIG = ( - os.environ.get("ENABLE_OAUTH_PERSISTENT_CONFIG", "False").lower() == "true" -) +ENABLE_OAUTH_PERSISTENT_CONFIG = os.environ.get('ENABLE_OAUTH_PERSISTENT_CONFIG', 'False').lower() == 'true' ENABLE_OAUTH_SIGNUP = PersistentConfig( - "ENABLE_OAUTH_SIGNUP", - "oauth.enable_signup", - os.environ.get("ENABLE_OAUTH_SIGNUP", "False").lower() == "true", + 'ENABLE_OAUTH_SIGNUP', + 'oauth.enable_signup', + os.environ.get('ENABLE_OAUTH_SIGNUP', 'False').lower() == 'true', ) OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = PersistentConfig( - "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", - "oauth.refresh_token_include_scope", - os.environ.get("OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", "False").lower() == "true", + 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', + 'oauth.refresh_token_include_scope', + os.environ.get('OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', 'False').lower() == 'true', ) OAUTH_MERGE_ACCOUNTS_BY_EMAIL = PersistentConfig( - "OAUTH_MERGE_ACCOUNTS_BY_EMAIL", - "oauth.merge_accounts_by_email", - os.environ.get("OAUTH_MERGE_ACCOUNTS_BY_EMAIL", "False").lower() == "true", + 'OAUTH_MERGE_ACCOUNTS_BY_EMAIL', + 'oauth.merge_accounts_by_email', + os.environ.get('OAUTH_MERGE_ACCOUNTS_BY_EMAIL', 'False').lower() == 'true', ) OAUTH_PROVIDERS = {} GOOGLE_CLIENT_ID = PersistentConfig( - "GOOGLE_CLIENT_ID", - "oauth.google.client_id", - os.environ.get("GOOGLE_CLIENT_ID", ""), + 'GOOGLE_CLIENT_ID', + 'oauth.google.client_id', + os.environ.get('GOOGLE_CLIENT_ID', ''), ) GOOGLE_CLIENT_SECRET = PersistentConfig( - "GOOGLE_CLIENT_SECRET", - "oauth.google.client_secret", - os.environ.get("GOOGLE_CLIENT_SECRET", ""), + 'GOOGLE_CLIENT_SECRET', + 'oauth.google.client_secret', + os.environ.get('GOOGLE_CLIENT_SECRET', ''), ) GOOGLE_OAUTH_SCOPE = PersistentConfig( - "GOOGLE_OAUTH_SCOPE", - "oauth.google.scope", - os.environ.get("GOOGLE_OAUTH_SCOPE", "openid email profile"), + 'GOOGLE_OAUTH_SCOPE', + 'oauth.google.scope', + os.environ.get('GOOGLE_OAUTH_SCOPE', 'openid email profile'), ) GOOGLE_REDIRECT_URI = PersistentConfig( - "GOOGLE_REDIRECT_URI", - "oauth.google.redirect_uri", - os.environ.get("GOOGLE_REDIRECT_URI", ""), + 'GOOGLE_REDIRECT_URI', + 'oauth.google.redirect_uri', + os.environ.get('GOOGLE_REDIRECT_URI', ''), ) +GOOGLE_OAUTH_AUTHORIZE_PARAMS = {} +_google_oauth_authorize_params = os.environ.get('GOOGLE_OAUTH_AUTHORIZE_PARAMS', '') +if _google_oauth_authorize_params: + try: + _parsed = json.loads(_google_oauth_authorize_params) + if isinstance(_parsed, dict): + GOOGLE_OAUTH_AUTHORIZE_PARAMS = _parsed + else: + log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring') + except (json.JSONDecodeError, TypeError): + log.warning('GOOGLE_OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') + MICROSOFT_CLIENT_ID = PersistentConfig( - "MICROSOFT_CLIENT_ID", - "oauth.microsoft.client_id", - os.environ.get("MICROSOFT_CLIENT_ID", ""), + 'MICROSOFT_CLIENT_ID', + 'oauth.microsoft.client_id', + os.environ.get('MICROSOFT_CLIENT_ID', ''), ) MICROSOFT_CLIENT_SECRET = PersistentConfig( - "MICROSOFT_CLIENT_SECRET", - "oauth.microsoft.client_secret", - os.environ.get("MICROSOFT_CLIENT_SECRET", ""), + 'MICROSOFT_CLIENT_SECRET', + 'oauth.microsoft.client_secret', + os.environ.get('MICROSOFT_CLIENT_SECRET', ''), ) MICROSOFT_CLIENT_TENANT_ID = PersistentConfig( - "MICROSOFT_CLIENT_TENANT_ID", - "oauth.microsoft.tenant_id", - os.environ.get("MICROSOFT_CLIENT_TENANT_ID", ""), + 'MICROSOFT_CLIENT_TENANT_ID', + 'oauth.microsoft.tenant_id', + os.environ.get('MICROSOFT_CLIENT_TENANT_ID', ''), ) MICROSOFT_CLIENT_LOGIN_BASE_URL = PersistentConfig( - "MICROSOFT_CLIENT_LOGIN_BASE_URL", - "oauth.microsoft.login_base_url", - os.environ.get( - "MICROSOFT_CLIENT_LOGIN_BASE_URL", "https://login.microsoftonline.com" - ), + 'MICROSOFT_CLIENT_LOGIN_BASE_URL', + 'oauth.microsoft.login_base_url', + os.environ.get('MICROSOFT_CLIENT_LOGIN_BASE_URL', 'https://login.microsoftonline.com'), ) MICROSOFT_CLIENT_PICTURE_URL = PersistentConfig( - "MICROSOFT_CLIENT_PICTURE_URL", - "oauth.microsoft.picture_url", + 'MICROSOFT_CLIENT_PICTURE_URL', + 'oauth.microsoft.picture_url', os.environ.get( - "MICROSOFT_CLIENT_PICTURE_URL", - "https://graph.microsoft.com/v1.0/me/photo/$value", + 'MICROSOFT_CLIENT_PICTURE_URL', + 'https://graph.microsoft.com/v1.0/me/photo/$value', ), ) MICROSOFT_OAUTH_SCOPE = PersistentConfig( - "MICROSOFT_OAUTH_SCOPE", - "oauth.microsoft.scope", - os.environ.get("MICROSOFT_OAUTH_SCOPE", "openid email profile"), + 'MICROSOFT_OAUTH_SCOPE', + 'oauth.microsoft.scope', + os.environ.get('MICROSOFT_OAUTH_SCOPE', 'openid email profile'), ) MICROSOFT_REDIRECT_URI = PersistentConfig( - "MICROSOFT_REDIRECT_URI", - "oauth.microsoft.redirect_uri", - os.environ.get("MICROSOFT_REDIRECT_URI", ""), + 'MICROSOFT_REDIRECT_URI', + 'oauth.microsoft.redirect_uri', + os.environ.get('MICROSOFT_REDIRECT_URI', ''), ) GITHUB_CLIENT_ID = PersistentConfig( - "GITHUB_CLIENT_ID", - "oauth.github.client_id", - os.environ.get("GITHUB_CLIENT_ID", ""), + 'GITHUB_CLIENT_ID', + 'oauth.github.client_id', + os.environ.get('GITHUB_CLIENT_ID', ''), ) GITHUB_CLIENT_SECRET = PersistentConfig( - "GITHUB_CLIENT_SECRET", - "oauth.github.client_secret", - os.environ.get("GITHUB_CLIENT_SECRET", ""), + 'GITHUB_CLIENT_SECRET', + 'oauth.github.client_secret', + os.environ.get('GITHUB_CLIENT_SECRET', ''), ) GITHUB_CLIENT_SCOPE = PersistentConfig( - "GITHUB_CLIENT_SCOPE", - "oauth.github.scope", - os.environ.get("GITHUB_CLIENT_SCOPE", "user:email"), + 'GITHUB_CLIENT_SCOPE', + 'oauth.github.scope', + os.environ.get('GITHUB_CLIENT_SCOPE', 'user:email'), ) GITHUB_CLIENT_REDIRECT_URI = PersistentConfig( - "GITHUB_CLIENT_REDIRECT_URI", - "oauth.github.redirect_uri", - os.environ.get("GITHUB_CLIENT_REDIRECT_URI", ""), + 'GITHUB_CLIENT_REDIRECT_URI', + 'oauth.github.redirect_uri', + os.environ.get('GITHUB_CLIENT_REDIRECT_URI', ''), ) OAUTH_CLIENT_ID = PersistentConfig( - "OAUTH_CLIENT_ID", - "oauth.oidc.client_id", - os.environ.get("OAUTH_CLIENT_ID", ""), + 'OAUTH_CLIENT_ID', + 'oauth.oidc.client_id', + os.environ.get('OAUTH_CLIENT_ID', ''), ) OAUTH_CLIENT_SECRET = PersistentConfig( - "OAUTH_CLIENT_SECRET", - "oauth.oidc.client_secret", - os.environ.get("OAUTH_CLIENT_SECRET", ""), + 'OAUTH_CLIENT_SECRET', + 'oauth.oidc.client_secret', + os.environ.get('OAUTH_CLIENT_SECRET', ''), ) OPENID_PROVIDER_URL = PersistentConfig( - "OPENID_PROVIDER_URL", - "oauth.oidc.provider_url", - os.environ.get("OPENID_PROVIDER_URL", ""), + 'OPENID_PROVIDER_URL', + 'oauth.oidc.provider_url', + os.environ.get('OPENID_PROVIDER_URL', ''), ) OPENID_END_SESSION_ENDPOINT = PersistentConfig( - "OPENID_END_SESSION_ENDPOINT", - "oauth.oidc.end_session_endpoint", - os.environ.get("OPENID_END_SESSION_ENDPOINT", ""), + 'OPENID_END_SESSION_ENDPOINT', + 'oauth.oidc.end_session_endpoint', + os.environ.get('OPENID_END_SESSION_ENDPOINT', ''), ) OPENID_REDIRECT_URI = PersistentConfig( - "OPENID_REDIRECT_URI", - "oauth.oidc.redirect_uri", - os.environ.get("OPENID_REDIRECT_URI", ""), + 'OPENID_REDIRECT_URI', + 'oauth.oidc.redirect_uri', + os.environ.get('OPENID_REDIRECT_URI', ''), ) OAUTH_SCOPES = PersistentConfig( - "OAUTH_SCOPES", - "oauth.oidc.scopes", - os.environ.get("OAUTH_SCOPES", "openid email profile"), + 'OAUTH_SCOPES', + 'oauth.oidc.scopes', + os.environ.get('OAUTH_SCOPES', 'openid email profile'), ) OAUTH_TIMEOUT = PersistentConfig( - "OAUTH_TIMEOUT", - "oauth.oidc.oauth_timeout", - os.environ.get("OAUTH_TIMEOUT", ""), + 'OAUTH_TIMEOUT', + 'oauth.oidc.oauth_timeout', + os.environ.get('OAUTH_TIMEOUT', ''), ) OAUTH_TOKEN_ENDPOINT_AUTH_METHOD = PersistentConfig( - "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD", - "oauth.oidc.token_endpoint_auth_method", - os.environ.get("OAUTH_TOKEN_ENDPOINT_AUTH_METHOD", None), + 'OAUTH_TOKEN_ENDPOINT_AUTH_METHOD', + 'oauth.oidc.token_endpoint_auth_method', + os.environ.get('OAUTH_TOKEN_ENDPOINT_AUTH_METHOD', None), ) OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig( - "OAUTH_CODE_CHALLENGE_METHOD", - "oauth.oidc.code_challenge_method", - os.environ.get("OAUTH_CODE_CHALLENGE_METHOD", None), + 'OAUTH_CODE_CHALLENGE_METHOD', + 'oauth.oidc.code_challenge_method', + os.environ.get('OAUTH_CODE_CHALLENGE_METHOD', None), ) OAUTH_PROVIDER_NAME = PersistentConfig( - "OAUTH_PROVIDER_NAME", - "oauth.oidc.provider_name", - os.environ.get("OAUTH_PROVIDER_NAME", "SSO"), + 'OAUTH_PROVIDER_NAME', + 'oauth.oidc.provider_name', + os.environ.get('OAUTH_PROVIDER_NAME', 'SSO'), ) OAUTH_SUB_CLAIM = PersistentConfig( - "OAUTH_SUB_CLAIM", - "oauth.oidc.sub_claim", - os.environ.get("OAUTH_SUB_CLAIM", None), + 'OAUTH_SUB_CLAIM', + 'oauth.oidc.sub_claim', + os.environ.get('OAUTH_SUB_CLAIM', None), ) OAUTH_USERNAME_CLAIM = PersistentConfig( - "OAUTH_USERNAME_CLAIM", - "oauth.oidc.username_claim", - os.environ.get("OAUTH_USERNAME_CLAIM", "name"), + 'OAUTH_USERNAME_CLAIM', + 'oauth.oidc.username_claim', + os.environ.get('OAUTH_USERNAME_CLAIM', 'name'), ) OAUTH_PICTURE_CLAIM = PersistentConfig( - "OAUTH_PICTURE_CLAIM", - "oauth.oidc.avatar_claim", - os.environ.get("OAUTH_PICTURE_CLAIM", "picture"), + 'OAUTH_PICTURE_CLAIM', + 'oauth.oidc.avatar_claim', + os.environ.get('OAUTH_PICTURE_CLAIM', 'picture'), ) OAUTH_EMAIL_CLAIM = PersistentConfig( - "OAUTH_EMAIL_CLAIM", - "oauth.oidc.email_claim", - os.environ.get("OAUTH_EMAIL_CLAIM", "email"), + 'OAUTH_EMAIL_CLAIM', + 'oauth.oidc.email_claim', + os.environ.get('OAUTH_EMAIL_CLAIM', 'email'), ) OAUTH_GROUPS_CLAIM = PersistentConfig( - "OAUTH_GROUPS_CLAIM", - "oauth.oidc.group_claim", - os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")), + 'OAUTH_GROUPS_CLAIM', + 'oauth.oidc.group_claim', + os.environ.get('OAUTH_GROUPS_CLAIM', os.environ.get('OAUTH_GROUP_CLAIM', 'groups')), ) FEISHU_CLIENT_ID = PersistentConfig( - "FEISHU_CLIENT_ID", - "oauth.feishu.client_id", - os.environ.get("FEISHU_CLIENT_ID", ""), + 'FEISHU_CLIENT_ID', + 'oauth.feishu.client_id', + os.environ.get('FEISHU_CLIENT_ID', ''), ) FEISHU_CLIENT_SECRET = PersistentConfig( - "FEISHU_CLIENT_SECRET", - "oauth.feishu.client_secret", - os.environ.get("FEISHU_CLIENT_SECRET", ""), + 'FEISHU_CLIENT_SECRET', + 'oauth.feishu.client_secret', + os.environ.get('FEISHU_CLIENT_SECRET', ''), ) FEISHU_OAUTH_SCOPE = PersistentConfig( - "FEISHU_OAUTH_SCOPE", - "oauth.feishu.scope", - os.environ.get("FEISHU_OAUTH_SCOPE", "contact:user.base:readonly"), + 'FEISHU_OAUTH_SCOPE', + 'oauth.feishu.scope', + os.environ.get('FEISHU_OAUTH_SCOPE', 'contact:user.base:readonly'), ) FEISHU_REDIRECT_URI = PersistentConfig( - "FEISHU_REDIRECT_URI", - "oauth.feishu.redirect_uri", - os.environ.get("FEISHU_REDIRECT_URI", ""), + 'FEISHU_REDIRECT_URI', + 'oauth.feishu.redirect_uri', + os.environ.get('FEISHU_REDIRECT_URI', ''), ) ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig( - "ENABLE_OAUTH_ROLE_MANAGEMENT", - "oauth.enable_role_mapping", - os.environ.get("ENABLE_OAUTH_ROLE_MANAGEMENT", "False").lower() == "true", + 'ENABLE_OAUTH_ROLE_MANAGEMENT', + 'oauth.enable_role_mapping', + os.environ.get('ENABLE_OAUTH_ROLE_MANAGEMENT', 'False').lower() == 'true', ) ENABLE_OAUTH_GROUP_MANAGEMENT = PersistentConfig( - "ENABLE_OAUTH_GROUP_MANAGEMENT", - "oauth.enable_group_mapping", - os.environ.get("ENABLE_OAUTH_GROUP_MANAGEMENT", "False").lower() == "true", + 'ENABLE_OAUTH_GROUP_MANAGEMENT', + 'oauth.enable_group_mapping', + os.environ.get('ENABLE_OAUTH_GROUP_MANAGEMENT', 'False').lower() == 'true', ) ENABLE_OAUTH_GROUP_CREATION = PersistentConfig( - "ENABLE_OAUTH_GROUP_CREATION", - "oauth.enable_group_creation", - os.environ.get("ENABLE_OAUTH_GROUP_CREATION", "False").lower() == "true", + 'ENABLE_OAUTH_GROUP_CREATION', + 'oauth.enable_group_creation', + os.environ.get('ENABLE_OAUTH_GROUP_CREATION', 'False').lower() == 'true', ) -oauth_group_default_share = ( - os.environ.get("OAUTH_GROUP_DEFAULT_SHARE", "true").strip().lower() -) +oauth_group_default_share = os.environ.get('OAUTH_GROUP_DEFAULT_SHARE', 'true').strip().lower() OAUTH_GROUP_DEFAULT_SHARE = PersistentConfig( - "OAUTH_GROUP_DEFAULT_SHARE", - "oauth.group_default_share", - ( - "members" - if oauth_group_default_share == "members" - else oauth_group_default_share == "true" - ), + 'OAUTH_GROUP_DEFAULT_SHARE', + 'oauth.group_default_share', + ('members' if oauth_group_default_share == 'members' else oauth_group_default_share == 'true'), ) OAUTH_BLOCKED_GROUPS = PersistentConfig( - "OAUTH_BLOCKED_GROUPS", - "oauth.blocked_groups", - os.environ.get("OAUTH_BLOCKED_GROUPS", "[]"), + 'OAUTH_BLOCKED_GROUPS', + 'oauth.blocked_groups', + os.environ.get('OAUTH_BLOCKED_GROUPS', '[]'), ) -OAUTH_GROUPS_SEPARATOR = os.environ.get("OAUTH_GROUPS_SEPARATOR", ";") +OAUTH_GROUPS_SEPARATOR = os.environ.get('OAUTH_GROUPS_SEPARATOR', ';') OAUTH_ROLES_CLAIM = PersistentConfig( - "OAUTH_ROLES_CLAIM", - "oauth.roles_claim", - os.environ.get("OAUTH_ROLES_CLAIM", "roles"), + 'OAUTH_ROLES_CLAIM', + 'oauth.roles_claim', + os.environ.get('OAUTH_ROLES_CLAIM', 'roles'), ) -OAUTH_ROLES_SEPARATOR = os.environ.get("OAUTH_ROLES_SEPARATOR", ",") +OAUTH_ROLES_SEPARATOR = os.environ.get('OAUTH_ROLES_SEPARATOR', ',') OAUTH_ALLOWED_ROLES = PersistentConfig( - "OAUTH_ALLOWED_ROLES", - "oauth.allowed_roles", + 'OAUTH_ALLOWED_ROLES', + 'oauth.allowed_roles', [ role.strip() - for role in os.environ.get( - "OAUTH_ALLOWED_ROLES", f"user{OAUTH_ROLES_SEPARATOR}admin" - ).split(OAUTH_ROLES_SEPARATOR) + for role in os.environ.get('OAUTH_ALLOWED_ROLES', f'user{OAUTH_ROLES_SEPARATOR}admin').split( + OAUTH_ROLES_SEPARATOR + ) if role ], ) OAUTH_ADMIN_ROLES = PersistentConfig( - "OAUTH_ADMIN_ROLES", - "oauth.admin_roles", - [ - role.strip() - for role in os.environ.get("OAUTH_ADMIN_ROLES", "admin").split( - OAUTH_ROLES_SEPARATOR - ) - if role - ], + 'OAUTH_ADMIN_ROLES', + 'oauth.admin_roles', + [role.strip() for role in os.environ.get('OAUTH_ADMIN_ROLES', 'admin').split(OAUTH_ROLES_SEPARATOR) if role], ) OAUTH_ALLOWED_DOMAINS = PersistentConfig( - "OAUTH_ALLOWED_DOMAINS", - "oauth.allowed_domains", - [ - domain.strip() - for domain in os.environ.get("OAUTH_ALLOWED_DOMAINS", "*").split(",") - ], + 'OAUTH_ALLOWED_DOMAINS', + 'oauth.allowed_domains', + [domain.strip() for domain in os.environ.get('OAUTH_ALLOWED_DOMAINS', '*').split(',')], ) OAUTH_UPDATE_PICTURE_ON_LOGIN = PersistentConfig( - "OAUTH_UPDATE_PICTURE_ON_LOGIN", - "oauth.update_picture_on_login", - os.environ.get("OAUTH_UPDATE_PICTURE_ON_LOGIN", "False").lower() == "true", + 'OAUTH_UPDATE_PICTURE_ON_LOGIN', + 'oauth.update_picture_on_login', + os.environ.get('OAUTH_UPDATE_PICTURE_ON_LOGIN', 'False').lower() == 'true', ) OAUTH_UPDATE_NAME_ON_LOGIN = PersistentConfig( - "OAUTH_UPDATE_NAME_ON_LOGIN", - "oauth.update_name_on_login", - os.environ.get("OAUTH_UPDATE_NAME_ON_LOGIN", "False").lower() == "true", + 'OAUTH_UPDATE_NAME_ON_LOGIN', + 'oauth.update_name_on_login', + os.environ.get('OAUTH_UPDATE_NAME_ON_LOGIN', 'False').lower() == 'true', ) OAUTH_UPDATE_EMAIL_ON_LOGIN = PersistentConfig( - "OAUTH_UPDATE_EMAIL_ON_LOGIN", - "oauth.update_email_on_login", - os.environ.get("OAUTH_UPDATE_EMAIL_ON_LOGIN", "False").lower() == "true", + 'OAUTH_UPDATE_EMAIL_ON_LOGIN', + 'oauth.update_email_on_login', + os.environ.get('OAUTH_UPDATE_EMAIL_ON_LOGIN', 'False').lower() == 'true', ) OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = ( - os.environ.get("OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID", "False").lower() - == "true" + os.environ.get('OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID', 'False').lower() == 'true' ) OAUTH_AUDIENCE = PersistentConfig( - "OAUTH_AUDIENCE", - "oauth.audience", - os.environ.get("OAUTH_AUDIENCE", ""), + 'OAUTH_AUDIENCE', + 'oauth.audience', + os.environ.get('OAUTH_AUDIENCE', ''), ) +OAUTH_AUTHORIZE_PARAMS = {} +_oauth_authorize_params = os.environ.get('OAUTH_AUTHORIZE_PARAMS', '') +if _oauth_authorize_params: + try: + _parsed = json.loads(_oauth_authorize_params) + if isinstance(_parsed, dict): + OAUTH_AUTHORIZE_PARAMS = _parsed + else: + log.warning('OAUTH_AUTHORIZE_PARAMS must be a JSON object, ignoring') + except (json.JSONDecodeError, TypeError): + log.warning('OAUTH_AUTHORIZE_PARAMS is not valid JSON, ignoring') + def load_oauth_providers(): OAUTH_PROVIDERS.clear() @@ -661,84 +650,69 @@ def load_oauth_providers(): def google_oauth_register(oauth: OAuth): client = oauth.register( - name="google", + name='google', client_id=GOOGLE_CLIENT_ID.value, client_secret=GOOGLE_CLIENT_SECRET.value, - server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", + server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', client_kwargs={ - "scope": GOOGLE_OAUTH_SCOPE.value, - **( - {"timeout": int(OAUTH_TIMEOUT.value)} - if OAUTH_TIMEOUT.value - else {} - ), + 'scope': GOOGLE_OAUTH_SCOPE.value, + **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), }, redirect_uri=GOOGLE_REDIRECT_URI.value, + **({'authorize_params': GOOGLE_OAUTH_AUTHORIZE_PARAMS} if GOOGLE_OAUTH_AUTHORIZE_PARAMS else {}), ) return client - OAUTH_PROVIDERS["google"] = { - "redirect_uri": GOOGLE_REDIRECT_URI.value, - "register": google_oauth_register, + OAUTH_PROVIDERS['google'] = { + 'redirect_uri': GOOGLE_REDIRECT_URI.value, + 'register': google_oauth_register, } - if ( - MICROSOFT_CLIENT_ID.value - and MICROSOFT_CLIENT_SECRET.value - and MICROSOFT_CLIENT_TENANT_ID.value - ): + if MICROSOFT_CLIENT_ID.value and MICROSOFT_CLIENT_SECRET.value and MICROSOFT_CLIENT_TENANT_ID.value: def microsoft_oauth_register(oauth: OAuth): client = oauth.register( - name="microsoft", + name='microsoft', client_id=MICROSOFT_CLIENT_ID.value, client_secret=MICROSOFT_CLIENT_SECRET.value, - server_metadata_url=f"{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}", + server_metadata_url=f'{MICROSOFT_CLIENT_LOGIN_BASE_URL.value}/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration?appid={MICROSOFT_CLIENT_ID.value}', client_kwargs={ - "scope": MICROSOFT_OAUTH_SCOPE.value, - **( - {"timeout": int(OAUTH_TIMEOUT.value)} - if OAUTH_TIMEOUT.value - else {} - ), + 'scope': MICROSOFT_OAUTH_SCOPE.value, + **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), }, redirect_uri=MICROSOFT_REDIRECT_URI.value, ) return client - OAUTH_PROVIDERS["microsoft"] = { - "redirect_uri": MICROSOFT_REDIRECT_URI.value, - "picture_url": MICROSOFT_CLIENT_PICTURE_URL.value, - "register": microsoft_oauth_register, + OAUTH_PROVIDERS['microsoft'] = { + 'redirect_uri': MICROSOFT_REDIRECT_URI.value, + 'picture_url': MICROSOFT_CLIENT_PICTURE_URL.value, + 'register': microsoft_oauth_register, } if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value: def github_oauth_register(oauth: OAuth): client = oauth.register( - name="github", + name='github', client_id=GITHUB_CLIENT_ID.value, client_secret=GITHUB_CLIENT_SECRET.value, - access_token_url="https://github.com/login/oauth/access_token", - authorize_url="https://github.com/login/oauth/authorize", - api_base_url="https://api.github.com", - userinfo_endpoint="https://api.github.com/user", + access_token_url='https://github.com/login/oauth/access_token', + 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.value, - **( - {"timeout": int(OAUTH_TIMEOUT.value)} - if OAUTH_TIMEOUT.value - else {} - ), + 'scope': GITHUB_CLIENT_SCOPE.value, + **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), }, redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value, ) return client - OAUTH_PROVIDERS["github"] = { - "redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value, - "register": github_oauth_register, - "sub_claim": "id", + OAUTH_PROVIDERS['github'] = { + 'redirect_uri': GITHUB_CLIENT_REDIRECT_URI.value, + 'register': github_oauth_register, + 'sub_claim': 'id', } if ( @@ -749,32 +723,25 @@ def github_oauth_register(oauth: OAuth): def oidc_oauth_register(oauth: OAuth): client_kwargs = { - "scope": OAUTH_SCOPES.value, + 'scope': OAUTH_SCOPES.value, **( - { - "token_endpoint_auth_method": OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value - } + {'token_endpoint_auth_method': OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value} if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value else {} ), - **( - {"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {} - ), + **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), } - if ( - OAUTH_CODE_CHALLENGE_METHOD.value - and OAUTH_CODE_CHALLENGE_METHOD.value == "S256" - ): - client_kwargs["code_challenge_method"] = "S256" + if OAUTH_CODE_CHALLENGE_METHOD.value and OAUTH_CODE_CHALLENGE_METHOD.value == 'S256': + client_kwargs['code_challenge_method'] = 'S256' elif OAUTH_CODE_CHALLENGE_METHOD.value: raise Exception( 'Code challenge methods other than "%s" not supported. Given: "%s"' - % ("S256", OAUTH_CODE_CHALLENGE_METHOD.value) + % ('S256', OAUTH_CODE_CHALLENGE_METHOD.value) ) client = oauth.register( - name="oidc", + name='oidc', client_id=OAUTH_CLIENT_ID.value, client_secret=OAUTH_CLIENT_SECRET.value, server_metadata_url=OPENID_PROVIDER_URL.value, @@ -783,62 +750,54 @@ def oidc_oauth_register(oauth: OAuth): ) return client - OAUTH_PROVIDERS["oidc"] = { - "name": OAUTH_PROVIDER_NAME.value, - "redirect_uri": OPENID_REDIRECT_URI.value, - "register": oidc_oauth_register, + OAUTH_PROVIDERS['oidc'] = { + 'name': OAUTH_PROVIDER_NAME.value, + 'redirect_uri': OPENID_REDIRECT_URI.value, + 'register': oidc_oauth_register, } if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value: def feishu_oauth_register(oauth: OAuth): client = oauth.register( - name="feishu", + name='feishu', client_id=FEISHU_CLIENT_ID.value, client_secret=FEISHU_CLIENT_SECRET.value, - access_token_url="https://open.feishu.cn/open-apis/authen/v2/oauth/token", - authorize_url="https://accounts.feishu.cn/open-apis/authen/v1/authorize", - api_base_url="https://open.feishu.cn/open-apis", - userinfo_endpoint="https://open.feishu.cn/open-apis/authen/v1/user_info", + access_token_url='https://open.feishu.cn/open-apis/authen/v2/oauth/token', + authorize_url='https://accounts.feishu.cn/open-apis/authen/v1/authorize', + api_base_url='https://open.feishu.cn/open-apis', + userinfo_endpoint='https://open.feishu.cn/open-apis/authen/v1/user_info', client_kwargs={ - "scope": FEISHU_OAUTH_SCOPE.value, - **( - {"timeout": int(OAUTH_TIMEOUT.value)} - if OAUTH_TIMEOUT.value - else {} - ), + 'scope': FEISHU_OAUTH_SCOPE.value, + **({'timeout': int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}), }, redirect_uri=FEISHU_REDIRECT_URI.value, ) return client - OAUTH_PROVIDERS["feishu"] = { - "register": feishu_oauth_register, - "sub_claim": "user_id", + OAUTH_PROVIDERS['feishu'] = { + 'register': feishu_oauth_register, + 'sub_claim': 'user_id', } configured_providers = [] if GOOGLE_CLIENT_ID.value: - configured_providers.append("Google") + configured_providers.append('Google') if MICROSOFT_CLIENT_ID.value: - configured_providers.append("Microsoft") + configured_providers.append('Microsoft') if GITHUB_CLIENT_ID.value: - configured_providers.append("GitHub") + configured_providers.append('GitHub') if FEISHU_CLIENT_ID.value: - configured_providers.append("Feishu") + configured_providers.append('Feishu') - if ( - configured_providers - and not OPENID_PROVIDER_URL.value - and not OPENID_END_SESSION_ENDPOINT.value - ): - provider_list = ", ".join(configured_providers) + if configured_providers and not OPENID_PROVIDER_URL.value and not OPENID_END_SESSION_ENDPOINT.value: + provider_list = ', '.join(configured_providers) log.warning( - f"โš ๏ธ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!" + f'โš ๏ธ OAuth providers configured ({provider_list}) but OPENID_PROVIDER_URL not set - logout will not work!' ) log.warning( f"Set OPENID_PROVIDER_URL to your OAuth provider's OpenID Connect discovery endpoint," - f" or set OPENID_END_SESSION_ENDPOINT to a custom logout URL to fix logout functionality." + f' or set OPENID_END_SESSION_ENDPOINT to a custom logout URL to fix logout functionality.' ) @@ -848,7 +807,7 @@ def feishu_oauth_register(oauth: OAuth): # Static DIR #################################### -STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve() +STATIC_DIR = Path(os.getenv('STATIC_DIR', OPEN_WEBUI_DIR / 'static')).resolve() try: if STATIC_DIR.exists(): @@ -861,46 +820,44 @@ def feishu_oauth_register(oauth: OAuth): except Exception as e: pass -for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"): +for file_path in (FRONTEND_BUILD_DIR / 'static').glob('**/*'): if file_path.is_file(): - target_path = STATIC_DIR / file_path.relative_to( - (FRONTEND_BUILD_DIR / "static") - ) + target_path = STATIC_DIR / file_path.relative_to((FRONTEND_BUILD_DIR / 'static')) target_path.parent.mkdir(parents=True, exist_ok=True) try: shutil.copyfile(file_path, target_path) except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f'An error occurred: {e}') -frontend_favicon = FRONTEND_BUILD_DIR / "static" / "favicon.png" +frontend_favicon = FRONTEND_BUILD_DIR / 'static' / 'favicon.png' if frontend_favicon.exists(): try: - shutil.copyfile(frontend_favicon, STATIC_DIR / "favicon.png") + shutil.copyfile(frontend_favicon, STATIC_DIR / 'favicon.png') except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f'An error occurred: {e}') -frontend_splash = FRONTEND_BUILD_DIR / "static" / "splash.png" +frontend_splash = FRONTEND_BUILD_DIR / 'static' / 'splash.png' if frontend_splash.exists(): try: - shutil.copyfile(frontend_splash, STATIC_DIR / "splash.png") + shutil.copyfile(frontend_splash, STATIC_DIR / 'splash.png') except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f'An error occurred: {e}') -frontend_loader = FRONTEND_BUILD_DIR / "static" / "loader.js" +frontend_loader = FRONTEND_BUILD_DIR / 'static' / 'loader.js' if frontend_loader.exists(): try: - shutil.copyfile(frontend_loader, STATIC_DIR / "loader.js") + shutil.copyfile(frontend_loader, STATIC_DIR / 'loader.js') except Exception as e: - logging.error(f"An error occurred: {e}") + logging.error(f'An error occurred: {e}') #################################### # CUSTOM_NAME (Legacy) #################################### -CUSTOM_NAME = os.environ.get("CUSTOM_NAME", "") +CUSTOM_NAME = os.environ.get('CUSTOM_NAME', '') if CUSTOM_NAME: WEBUI_NAME = CUSTOM_NAME @@ -908,41 +865,37 @@ def feishu_oauth_register(oauth: OAuth): # STORAGE PROVIDER #################################### -STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "local") # defaults to local, s3 +STORAGE_PROVIDER = os.environ.get('STORAGE_PROVIDER', 'local') # defaults to local, s3 -S3_ACCESS_KEY_ID = os.environ.get("S3_ACCESS_KEY_ID", None) -S3_SECRET_ACCESS_KEY = os.environ.get("S3_SECRET_ACCESS_KEY", None) -S3_REGION_NAME = os.environ.get("S3_REGION_NAME", None) -S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None) -S3_KEY_PREFIX = os.environ.get("S3_KEY_PREFIX", None) -S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None) -S3_USE_ACCELERATE_ENDPOINT = ( - os.environ.get("S3_USE_ACCELERATE_ENDPOINT", "false").lower() == "true" -) -S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE", None) -S3_ENABLE_TAGGING = os.getenv("S3_ENABLE_TAGGING", "false").lower() == "true" +S3_ACCESS_KEY_ID = os.environ.get('S3_ACCESS_KEY_ID', None) +S3_SECRET_ACCESS_KEY = os.environ.get('S3_SECRET_ACCESS_KEY', None) +S3_REGION_NAME = os.environ.get('S3_REGION_NAME', None) +S3_BUCKET_NAME = os.environ.get('S3_BUCKET_NAME', None) +S3_KEY_PREFIX = os.environ.get('S3_KEY_PREFIX', None) +S3_ENDPOINT_URL = os.environ.get('S3_ENDPOINT_URL', None) +S3_USE_ACCELERATE_ENDPOINT = os.environ.get('S3_USE_ACCELERATE_ENDPOINT', 'false').lower() == 'true' +S3_ADDRESSING_STYLE = os.environ.get('S3_ADDRESSING_STYLE', None) +S3_ENABLE_TAGGING = os.getenv('S3_ENABLE_TAGGING', 'false').lower() == 'true' -GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None) -GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get( - "GOOGLE_APPLICATION_CREDENTIALS_JSON", None -) +GCS_BUCKET_NAME = os.environ.get('GCS_BUCKET_NAME', None) +GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS_JSON', None) -AZURE_STORAGE_ENDPOINT = os.environ.get("AZURE_STORAGE_ENDPOINT", None) -AZURE_STORAGE_CONTAINER_NAME = os.environ.get("AZURE_STORAGE_CONTAINER_NAME", None) -AZURE_STORAGE_KEY = os.environ.get("AZURE_STORAGE_KEY", None) +AZURE_STORAGE_ENDPOINT = os.environ.get('AZURE_STORAGE_ENDPOINT', None) +AZURE_STORAGE_CONTAINER_NAME = os.environ.get('AZURE_STORAGE_CONTAINER_NAME', None) +AZURE_STORAGE_KEY = os.environ.get('AZURE_STORAGE_KEY', None) #################################### # File Upload DIR #################################### -UPLOAD_DIR = DATA_DIR / "uploads" +UPLOAD_DIR = DATA_DIR / 'uploads' UPLOAD_DIR.mkdir(parents=True, exist_ok=True) #################################### # Cache DIR #################################### -CACHE_DIR = DATA_DIR / "cache" +CACHE_DIR = DATA_DIR / 'cache' CACHE_DIR.mkdir(parents=True, exist_ok=True) #################################### @@ -950,9 +903,9 @@ def feishu_oauth_register(oauth: OAuth): #################################### ENABLE_DIRECT_CONNECTIONS = PersistentConfig( - "ENABLE_DIRECT_CONNECTIONS", - "direct.enable", - os.environ.get("ENABLE_DIRECT_CONNECTIONS", "False").lower() == "true", + 'ENABLE_DIRECT_CONNECTIONS', + 'direct.enable', + os.environ.get('ENABLE_DIRECT_CONNECTIONS', 'False').lower() == 'true', ) #################################### @@ -960,42 +913,34 @@ def feishu_oauth_register(oauth: OAuth): #################################### ENABLE_OLLAMA_API = PersistentConfig( - "ENABLE_OLLAMA_API", - "ollama.enable", - os.environ.get("ENABLE_OLLAMA_API", "True").lower() == "true", + 'ENABLE_OLLAMA_API', + 'ollama.enable', + os.environ.get('ENABLE_OLLAMA_API', 'True').lower() == 'true', ) -OLLAMA_API_BASE_URL = os.environ.get( - "OLLAMA_API_BASE_URL", "http://localhost:11434/api" -) +OLLAMA_API_BASE_URL = os.environ.get('OLLAMA_API_BASE_URL', 'http://localhost:11434/api') -OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "") +OLLAMA_BASE_URL = os.environ.get('OLLAMA_BASE_URL', '') if OLLAMA_BASE_URL: # Remove trailing slash - OLLAMA_BASE_URL = ( - OLLAMA_BASE_URL[:-1] if OLLAMA_BASE_URL.endswith("/") else OLLAMA_BASE_URL - ) + OLLAMA_BASE_URL = OLLAMA_BASE_URL[:-1] if OLLAMA_BASE_URL.endswith('/') else OLLAMA_BASE_URL -K8S_FLAG = os.environ.get("K8S_FLAG", "") -USE_OLLAMA_DOCKER = os.environ.get("USE_OLLAMA_DOCKER", "false") +K8S_FLAG = os.environ.get('K8S_FLAG', '') +USE_OLLAMA_DOCKER = os.environ.get('USE_OLLAMA_DOCKER', 'false') -if OLLAMA_BASE_URL == "" and OLLAMA_API_BASE_URL != "": - OLLAMA_BASE_URL = ( - OLLAMA_API_BASE_URL[:-4] - if OLLAMA_API_BASE_URL.endswith("/api") - else OLLAMA_API_BASE_URL - ) +if OLLAMA_BASE_URL == '' and OLLAMA_API_BASE_URL != '': + OLLAMA_BASE_URL = OLLAMA_API_BASE_URL[:-4] if OLLAMA_API_BASE_URL.endswith('/api') else OLLAMA_API_BASE_URL -if ENV == "prod": - if OLLAMA_BASE_URL == "/ollama" and not K8S_FLAG: - if USE_OLLAMA_DOCKER.lower() == "true": +if ENV == 'prod': + if OLLAMA_BASE_URL == '/ollama' and not K8S_FLAG: + if USE_OLLAMA_DOCKER.lower() == 'true': # if you use all-in-one docker container (Open WebUI + Ollama) # with the docker build arg USE_OLLAMA=true (--build-arg="USE_OLLAMA=true") this only works with http://localhost:11434 - OLLAMA_BASE_URL = "http://localhost:11434" + OLLAMA_BASE_URL = 'http://localhost:11434' else: - OLLAMA_BASE_URL = "http://host.docker.internal:11434" + OLLAMA_BASE_URL = 'http://host.docker.internal:11434' elif K8S_FLAG: - OLLAMA_BASE_URL = "http://ollama-service.open-webui.svc.cluster.local:11434" + OLLAMA_BASE_URL = 'http://ollama-service.open-webui.svc.cluster.local:11434' def _resolve_ollama_base_url(url: str) -> str: @@ -1008,40 +953,36 @@ def reachable(host: str, port: int) -> bool: except (OSError, TimeoutError): return False - host = urlparse(url).hostname or "localhost" + host = urlparse(url).hostname or 'localhost' with ThreadPoolExecutor(max_workers=2) as pool: default = pool.submit(reachable, host, 11434) fallback = pool.submit(reachable, host, 12434) if not default.result() and fallback.result(): - url = url.replace(":11434", ":12434") - log.info(f"Ollama port 11434 unreachable on {host}, falling back to 12434") + url = url.replace(':11434', ':12434') + log.info(f'Ollama port 11434 unreachable on {host}, falling back to 12434') elif not default.result(): - log.info(f"Ollama ports 11434 and 12434 both unreachable on {host}") + log.info(f'Ollama ports 11434 and 12434 both unreachable on {host}') return url # Auto-resolve Ollama port when no explicit URL was provided by the user. # The Dockerfile default is "/ollama" which the block above rewrites to :11434. -if os.environ.get("OLLAMA_BASE_URL", "") in ("", "/ollama") and not os.environ.get( - "OLLAMA_BASE_URLS", "" -): +if os.environ.get('OLLAMA_BASE_URL', '') in ('', '/ollama') and not os.environ.get('OLLAMA_BASE_URLS', ''): OLLAMA_BASE_URL = _resolve_ollama_base_url(OLLAMA_BASE_URL) -OLLAMA_BASE_URLS = os.environ.get("OLLAMA_BASE_URLS", "") -OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != "" else OLLAMA_BASE_URL +OLLAMA_BASE_URLS = os.environ.get('OLLAMA_BASE_URLS', '') +OLLAMA_BASE_URLS = OLLAMA_BASE_URLS if OLLAMA_BASE_URLS != '' else OLLAMA_BASE_URL -OLLAMA_BASE_URLS = [url.strip() for url in OLLAMA_BASE_URLS.split(";")] -OLLAMA_BASE_URLS = PersistentConfig( - "OLLAMA_BASE_URLS", "ollama.base_urls", OLLAMA_BASE_URLS -) +OLLAMA_BASE_URLS = [url.strip() for url in OLLAMA_BASE_URLS.split(';')] +OLLAMA_BASE_URLS = PersistentConfig('OLLAMA_BASE_URLS', 'ollama.base_urls', OLLAMA_BASE_URLS) OLLAMA_API_CONFIGS = PersistentConfig( - "OLLAMA_API_CONFIGS", - "ollama.api_configs", + 'OLLAMA_API_CONFIGS', + 'ollama.api_configs', {}, ) @@ -1051,68 +992,59 @@ def reachable(host: str, port: int) -> bool: ENABLE_OPENAI_API = PersistentConfig( - "ENABLE_OPENAI_API", - "openai.enable", - os.environ.get("ENABLE_OPENAI_API", "True").lower() == "true", + 'ENABLE_OPENAI_API', + 'openai.enable', + os.environ.get('ENABLE_OPENAI_API', 'True').lower() == 'true', ) -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") -OPENAI_API_BASE_URL = os.environ.get("OPENAI_API_BASE_URL", "") +OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '') +OPENAI_API_BASE_URL = os.environ.get('OPENAI_API_BASE_URL', '') -GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") -GEMINI_API_BASE_URL = os.environ.get("GEMINI_API_BASE_URL", "") +GEMINI_API_KEY = os.environ.get('GEMINI_API_KEY', '') +GEMINI_API_BASE_URL = os.environ.get('GEMINI_API_BASE_URL', '') -if OPENAI_API_BASE_URL == "": - OPENAI_API_BASE_URL = "https://api.openai.com/v1" +if OPENAI_API_BASE_URL == '': + OPENAI_API_BASE_URL = 'https://api.openai.com/v1' else: - if OPENAI_API_BASE_URL.endswith("/"): + if OPENAI_API_BASE_URL.endswith('/'): OPENAI_API_BASE_URL = OPENAI_API_BASE_URL[:-1] -OPENAI_API_KEYS = os.environ.get("OPENAI_API_KEYS", "") -OPENAI_API_KEYS = OPENAI_API_KEYS if OPENAI_API_KEYS != "" else OPENAI_API_KEY +OPENAI_API_KEYS = os.environ.get('OPENAI_API_KEYS', '') +OPENAI_API_KEYS = OPENAI_API_KEYS if OPENAI_API_KEYS != '' else OPENAI_API_KEY -OPENAI_API_KEYS = [url.strip() for url in OPENAI_API_KEYS.split(";")] -OPENAI_API_KEYS = PersistentConfig( - "OPENAI_API_KEYS", "openai.api_keys", OPENAI_API_KEYS -) +OPENAI_API_KEYS = [url.strip() for url in OPENAI_API_KEYS.split(';')] +OPENAI_API_KEYS = PersistentConfig('OPENAI_API_KEYS', 'openai.api_keys', OPENAI_API_KEYS) -OPENAI_API_BASE_URLS = os.environ.get("OPENAI_API_BASE_URLS", "") -OPENAI_API_BASE_URLS = ( - OPENAI_API_BASE_URLS if OPENAI_API_BASE_URLS != "" else OPENAI_API_BASE_URL -) +OPENAI_API_BASE_URLS = os.environ.get('OPENAI_API_BASE_URLS', '') +OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS if OPENAI_API_BASE_URLS != '' else OPENAI_API_BASE_URL OPENAI_API_BASE_URLS = [ - url.strip() if url != "" else "https://api.openai.com/v1" - for url in OPENAI_API_BASE_URLS.split(";") + url.strip() if url != '' else 'https://api.openai.com/v1' for url in OPENAI_API_BASE_URLS.split(';') ] -OPENAI_API_BASE_URLS = PersistentConfig( - "OPENAI_API_BASE_URLS", "openai.api_base_urls", OPENAI_API_BASE_URLS -) +OPENAI_API_BASE_URLS = PersistentConfig('OPENAI_API_BASE_URLS', 'openai.api_base_urls', OPENAI_API_BASE_URLS) OPENAI_API_CONFIGS = PersistentConfig( - "OPENAI_API_CONFIGS", - "openai.api_configs", + 'OPENAI_API_CONFIGS', + 'openai.api_configs', {}, ) # Get the actual OpenAI API key based on the base URL -OPENAI_API_KEY = "" +OPENAI_API_KEY = '' try: - OPENAI_API_KEY = OPENAI_API_KEYS.value[ - OPENAI_API_BASE_URLS.value.index("https://api.openai.com/v1") - ] + OPENAI_API_KEY = OPENAI_API_KEYS.value[OPENAI_API_BASE_URLS.value.index('https://api.openai.com/v1')] except Exception: pass -OPENAI_API_BASE_URL = "https://api.openai.com/v1" +OPENAI_API_BASE_URL = 'https://api.openai.com/v1' #################################### # MODELS #################################### ENABLE_BASE_MODELS_CACHE = PersistentConfig( - "ENABLE_BASE_MODELS_CACHE", - "models.base_models_cache", - os.environ.get("ENABLE_BASE_MODELS_CACHE", "False").lower() == "true", + 'ENABLE_BASE_MODELS_CACHE', + 'models.base_models_cache', + os.environ.get('ENABLE_BASE_MODELS_CACHE', 'False').lower() == 'true', ) #################################### @@ -1120,16 +1052,14 @@ def reachable(host: str, port: int) -> bool: #################################### try: - tool_server_connections = json.loads( - os.environ.get("TOOL_SERVER_CONNECTIONS", "[]") - ) + tool_server_connections = json.loads(os.environ.get('TOOL_SERVER_CONNECTIONS', '[]')) except Exception as e: - log.exception(f"Error loading TOOL_SERVER_CONNECTIONS: {e}") + log.exception(f'Error loading TOOL_SERVER_CONNECTIONS: {e}') tool_server_connections = [] TOOL_SERVER_CONNECTIONS = PersistentConfig( - "TOOL_SERVER_CONNECTIONS", - "tool_server.connections", + 'TOOL_SERVER_CONNECTIONS', + 'tool_server.connections', tool_server_connections, ) @@ -1137,13 +1067,11 @@ def reachable(host: str, port: int) -> bool: # TERMINAL_SERVER #################################### -terminal_server_connections = json.loads( - os.environ.get("TERMINAL_SERVER_CONNECTIONS", "[]") -) +terminal_server_connections = json.loads(os.environ.get('TERMINAL_SERVER_CONNECTIONS', '[]')) TERMINAL_SERVER_CONNECTIONS = PersistentConfig( - "TERMINAL_SERVER_CONNECTIONS", - "terminal_server.connections", + 'TERMINAL_SERVER_CONNECTIONS', + 'terminal_server.connections', terminal_server_connections, ) @@ -1152,611 +1080,510 @@ def reachable(host: str, port: int) -> bool: #################################### -WEBUI_URL = PersistentConfig("WEBUI_URL", "webui.url", os.environ.get("WEBUI_URL", "")) +WEBUI_URL = PersistentConfig('WEBUI_URL', 'webui.url', os.environ.get('WEBUI_URL', '')) ENABLE_SIGNUP = PersistentConfig( - "ENABLE_SIGNUP", - "ui.enable_signup", - ( - False - if not WEBUI_AUTH - else os.environ.get("ENABLE_SIGNUP", "True").lower() == "true" - ), + 'ENABLE_SIGNUP', + 'ui.enable_signup', + (False if not WEBUI_AUTH else os.environ.get('ENABLE_SIGNUP', 'True').lower() == 'true'), ) ENABLE_LOGIN_FORM = PersistentConfig( - "ENABLE_LOGIN_FORM", - "ui.ENABLE_LOGIN_FORM", - os.environ.get("ENABLE_LOGIN_FORM", "True").lower() == "true", + 'ENABLE_LOGIN_FORM', + 'ui.ENABLE_LOGIN_FORM', + os.environ.get('ENABLE_LOGIN_FORM', 'True').lower() == 'true', ) -ENABLE_PASSWORD_AUTH = os.environ.get("ENABLE_PASSWORD_AUTH", "True").lower() == "true" +ENABLE_PASSWORD_AUTH = os.environ.get('ENABLE_PASSWORD_AUTH', 'True').lower() == 'true' ENABLE_SIGNUP_VERIFY = PersistentConfig( - "ENABLE_SIGNUP_VERIFY", - "ui.signup_verify.enabled", - os.environ.get("ENABLE_SIGNUP_VERIFY", "False").lower() == "true", + 'ENABLE_SIGNUP_VERIFY', + 'ui.signup_verify.enabled', + os.environ.get('ENABLE_SIGNUP_VERIFY', 'False').lower() == 'true', ) SIGNUP_EMAIL_DOMAIN_WHITELIST = PersistentConfig( - "SIGNUP_EMAIL_DOMAIN_WHITELIST", - "ui.signup.email_domain_whitelist", - os.environ.get("SIGNUP_EMAIL_DOMAIN_WHITELIST", ""), + 'SIGNUP_EMAIL_DOMAIN_WHITELIST', + 'ui.signup.email_domain_whitelist', + os.environ.get('SIGNUP_EMAIL_DOMAIN_WHITELIST', ''), ) SMTP_HOST = PersistentConfig( - "SMTP_HOST", - "ui.smtp.host", - os.environ.get("SMTP_HOST", ""), + 'SMTP_HOST', + 'ui.smtp.host', + os.environ.get('SMTP_HOST', ''), ) SMTP_PORT = PersistentConfig( - "SMTP_PORT", - "ui.smtp.port", - os.environ.get("SMTP_PORT", "465"), + 'SMTP_PORT', + 'ui.smtp.port', + os.environ.get('SMTP_PORT', '465'), ) SMTP_USERNAME = PersistentConfig( - "SMTP_USERNAME", - "ui.smtp.username", - os.environ.get("SMTP_USERNAME", ""), + 'SMTP_USERNAME', + 'ui.smtp.username', + os.environ.get('SMTP_USERNAME', ''), ) SMTP_PASSWORD = PersistentConfig( - "SMTP_PASSWORD", - "ui.smtp.password", - os.environ.get("SMTP_PASSWORD", ""), + 'SMTP_PASSWORD', + 'ui.smtp.password', + os.environ.get('SMTP_PASSWORD', ''), ) SMTP_SENT_FROM = PersistentConfig( - "SMTP_SENT_FROM", - "ui.smtp.sent_from", - os.environ.get("SMTP_SENT_FROM", ""), + 'SMTP_SENT_FROM', + 'ui.smtp.sent_from', + os.environ.get('SMTP_SENT_FROM', ''), ) DEFAULT_LOCALE = PersistentConfig( - "DEFAULT_LOCALE", - "ui.default_locale", - os.environ.get("DEFAULT_LOCALE", ""), + 'DEFAULT_LOCALE', + 'ui.default_locale', + os.environ.get('DEFAULT_LOCALE', ''), ) -DEFAULT_MODELS = PersistentConfig( - "DEFAULT_MODELS", "ui.default_models", os.environ.get("DEFAULT_MODELS", None) -) +DEFAULT_MODELS = PersistentConfig('DEFAULT_MODELS', 'ui.default_models', os.environ.get('DEFAULT_MODELS', None)) DEFAULT_PINNED_MODELS = PersistentConfig( - "DEFAULT_PINNED_MODELS", - "ui.default_pinned_models", - os.environ.get("DEFAULT_PINNED_MODELS", None), + 'DEFAULT_PINNED_MODELS', + 'ui.default_pinned_models', + os.environ.get('DEFAULT_PINNED_MODELS', None), ) try: - default_prompt_suggestions = json.loads( - os.environ.get("DEFAULT_PROMPT_SUGGESTIONS", "[]") - ) + default_prompt_suggestions = json.loads(os.environ.get('DEFAULT_PROMPT_SUGGESTIONS', '[]')) except Exception as e: - log.exception(f"Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}") + log.exception(f'Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}') default_prompt_suggestions = [] if default_prompt_suggestions == []: default_prompt_suggestions = [ { - "title": ["Help me study", "vocabulary for a college entrance exam"], - "content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.", + 'title': ['Help me study', 'vocabulary for a college entrance exam'], + 'content': "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.", }, { - "title": ["Give me ideas", "for what to do with my kids' art"], - "content": "What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.", + 'title': ['Give me ideas', "for what to do with my kids' art"], + 'content': "What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.", }, { - "title": ["Tell me a fun fact", "about the Roman Empire"], - "content": "Tell me a random fun fact about the Roman Empire", + 'title': ['Tell me a fun fact', 'about the Roman Empire'], + 'content': 'Tell me a random fun fact about the Roman Empire', }, { - "title": ["Show me a code snippet", "of a website's sticky header"], - "content": "Show me a code snippet of a website's sticky header in CSS and JavaScript.", + 'title': ['Show me a code snippet', "of a website's sticky header"], + 'content': "Show me a code snippet of a website's sticky header in CSS and JavaScript.", }, { - "title": [ - "Explain options trading", + 'title': [ + 'Explain options trading', "if I'm familiar with buying and selling stocks", ], - "content": "Explain options trading in simple terms if I'm familiar with buying and selling stocks.", + 'content': "Explain options trading in simple terms if I'm familiar with buying and selling stocks.", }, { - "title": ["Overcome procrastination", "give me tips"], - "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?", + 'title': ['Overcome procrastination', 'give me tips'], + 'content': 'Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?', }, ] DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig( - "DEFAULT_PROMPT_SUGGESTIONS", - "ui.prompt_suggestions", + 'DEFAULT_PROMPT_SUGGESTIONS', + 'ui.prompt_suggestions', default_prompt_suggestions, ) MODEL_ORDER_LIST = PersistentConfig( - "MODEL_ORDER_LIST", - "ui.model_order_list", + 'MODEL_ORDER_LIST', + 'ui.model_order_list', [], ) DEFAULT_MODEL_METADATA = PersistentConfig( - "DEFAULT_MODEL_METADATA", - "models.default_metadata", + 'DEFAULT_MODEL_METADATA', + 'models.default_metadata', {}, ) DEFAULT_MODEL_PARAMS = PersistentConfig( - "DEFAULT_MODEL_PARAMS", - "models.default_params", + 'DEFAULT_MODEL_PARAMS', + 'models.default_params', {}, ) DEFAULT_USER_ROLE = PersistentConfig( - "DEFAULT_USER_ROLE", - "ui.default_user_role", - os.getenv("DEFAULT_USER_ROLE", "pending"), + 'DEFAULT_USER_ROLE', + 'ui.default_user_role', + os.getenv('DEFAULT_USER_ROLE', 'pending'), ) DEFAULT_GROUP_ID = PersistentConfig( - "DEFAULT_GROUP_ID", - "ui.default_group_id", - os.environ.get("DEFAULT_GROUP_ID", ""), + 'DEFAULT_GROUP_ID', + 'ui.default_group_id', + os.environ.get('DEFAULT_GROUP_ID', ''), ) PENDING_USER_OVERLAY_TITLE = PersistentConfig( - "PENDING_USER_OVERLAY_TITLE", - "ui.pending_user_overlay_title", - os.environ.get("PENDING_USER_OVERLAY_TITLE", ""), + 'PENDING_USER_OVERLAY_TITLE', + 'ui.pending_user_overlay_title', + os.environ.get('PENDING_USER_OVERLAY_TITLE', ''), ) PENDING_USER_OVERLAY_CONTENT = PersistentConfig( - "PENDING_USER_OVERLAY_CONTENT", - "ui.pending_user_overlay_content", - os.environ.get("PENDING_USER_OVERLAY_CONTENT", ""), + 'PENDING_USER_OVERLAY_CONTENT', + 'ui.pending_user_overlay_content', + os.environ.get('PENDING_USER_OVERLAY_CONTENT', ''), ) RESPONSE_WATERMARK = PersistentConfig( - "RESPONSE_WATERMARK", - "ui.watermark", - os.environ.get("RESPONSE_WATERMARK", ""), + 'RESPONSE_WATERMARK', + 'ui.watermark', + os.environ.get('RESPONSE_WATERMARK', ''), ) USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT", "False").lower() == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT", "False").lower() == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = ( - os.environ.get( - "USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING", "False" - ).lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = ( - os.environ.get( - "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING", "False" - ).lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING = ( - os.environ.get( - "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False" - ).lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING = ( - os.environ.get( - "USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING", "False" - ).lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = ( - os.environ.get( - "USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING", "False" - ).lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING = ( - os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING = ( - os.environ.get( - "USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING", "False" - ).lower() - == "true" + os.environ.get('USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) -USER_PERMISSIONS_NOTES_ALLOW_SHARING = ( - os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower() == "true" -) +USER_PERMISSIONS_NOTES_ALLOW_SHARING = os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_SHARING', 'False').lower() == 'true' USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = ( - os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true' ) USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = ( - os.environ.get("USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS", "True").lower() - == "true" + os.environ.get('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true' ) -USER_PERMISSIONS_CHAT_CONTROLS = ( - os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_CONTROLS = os.environ.get('USER_PERMISSIONS_CHAT_CONTROLS', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_VALVES = ( - os.environ.get("USER_PERMISSIONS_CHAT_VALVES", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_VALVES = os.environ.get('USER_PERMISSIONS_CHAT_VALVES', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_SYSTEM_PROMPT = ( - os.environ.get("USER_PERMISSIONS_CHAT_SYSTEM_PROMPT", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_SYSTEM_PROMPT = os.environ.get('USER_PERMISSIONS_CHAT_SYSTEM_PROMPT', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_PARAMS = ( - os.environ.get("USER_PERMISSIONS_CHAT_PARAMS", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_PARAMS = os.environ.get('USER_PERMISSIONS_CHAT_PARAMS', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_FILE_UPLOAD = ( - os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_FILE_UPLOAD = os.environ.get('USER_PERMISSIONS_CHAT_FILE_UPLOAD', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_WEB_UPLOAD = ( - os.environ.get("USER_PERMISSIONS_CHAT_WEB_UPLOAD", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_WEB_UPLOAD = os.environ.get('USER_PERMISSIONS_CHAT_WEB_UPLOAD', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_DELETE = ( - os.environ.get("USER_PERMISSIONS_CHAT_DELETE", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_DELETE = os.environ.get('USER_PERMISSIONS_CHAT_DELETE', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_DELETE_MESSAGE = ( - os.environ.get("USER_PERMISSIONS_CHAT_DELETE_MESSAGE", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_DELETE_MESSAGE = os.environ.get('USER_PERMISSIONS_CHAT_DELETE_MESSAGE', 'True').lower() == 'true' USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE = ( - os.environ.get("USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE", "True").lower() == "true" + os.environ.get('USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE', 'True').lower() == 'true' ) USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE = ( - os.environ.get("USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE", "True").lower() - == "true" + os.environ.get('USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE', 'True').lower() == 'true' ) -USER_PERMISSIONS_CHAT_RATE_RESPONSE = ( - os.environ.get("USER_PERMISSIONS_CHAT_RATE_RESPONSE", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_RATE_RESPONSE = os.environ.get('USER_PERMISSIONS_CHAT_RATE_RESPONSE', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_EDIT = ( - os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_EDIT = os.environ.get('USER_PERMISSIONS_CHAT_EDIT', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_SHARE = ( - os.environ.get("USER_PERMISSIONS_CHAT_SHARE", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_SHARE = os.environ.get('USER_PERMISSIONS_CHAT_SHARE', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_EXPORT = ( - os.environ.get("USER_PERMISSIONS_CHAT_EXPORT", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_EXPORT = os.environ.get('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_STT = ( - os.environ.get("USER_PERMISSIONS_CHAT_STT", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_STT = os.environ.get('USER_PERMISSIONS_CHAT_STT', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_TTS = ( - os.environ.get("USER_PERMISSIONS_CHAT_TTS", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_TTS = os.environ.get('USER_PERMISSIONS_CHAT_TTS', 'True').lower() == 'true' -USER_PERMISSIONS_CHAT_CALL = ( - os.environ.get("USER_PERMISSIONS_CHAT_CALL", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_CALL = os.environ.get('USER_PERMISSIONS_CHAT_CALL', 'True').lower() == 'true' USER_PERMISSIONS_CHAT_MULTIPLE_MODELS = ( - os.environ.get("USER_PERMISSIONS_CHAT_MULTIPLE_MODELS", "True").lower() == "true" + os.environ.get('USER_PERMISSIONS_CHAT_MULTIPLE_MODELS', 'True').lower() == 'true' ) -USER_PERMISSIONS_CHAT_TEMPORARY = ( - os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true" -) +USER_PERMISSIONS_CHAT_TEMPORARY = os.environ.get('USER_PERMISSIONS_CHAT_TEMPORARY', 'True').lower() == 'true' USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED = ( - os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED', 'False').lower() == 'true' ) USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS = ( - os.environ.get("USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS", "False").lower() - == "true" + os.environ.get('USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS', 'False').lower() == 'true' ) -USER_PERMISSIONS_FEATURES_WEB_SEARCH = ( - os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true" -) +USER_PERMISSIONS_FEATURES_WEB_SEARCH = os.environ.get('USER_PERMISSIONS_FEATURES_WEB_SEARCH', 'True').lower() == 'true' USER_PERMISSIONS_FEATURES_IMAGE_GENERATION = ( - os.environ.get("USER_PERMISSIONS_FEATURES_IMAGE_GENERATION", "True").lower() - == "true" + os.environ.get('USER_PERMISSIONS_FEATURES_IMAGE_GENERATION', 'True').lower() == 'true' ) USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = ( - os.environ.get("USER_PERMISSIONS_FEATURES_CODE_INTERPRETER", "True").lower() - == "true" + os.environ.get('USER_PERMISSIONS_FEATURES_CODE_INTERPRETER', 'True').lower() == 'true' ) -USER_PERMISSIONS_FEATURES_FOLDERS = ( - os.environ.get("USER_PERMISSIONS_FEATURES_FOLDERS", "True").lower() == "true" -) +USER_PERMISSIONS_FEATURES_FOLDERS = os.environ.get('USER_PERMISSIONS_FEATURES_FOLDERS', 'True').lower() == 'true' -USER_PERMISSIONS_FEATURES_NOTES = ( - os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true" -) +USER_PERMISSIONS_FEATURES_NOTES = os.environ.get('USER_PERMISSIONS_FEATURES_NOTES', 'True').lower() == 'true' -USER_PERMISSIONS_FEATURES_CHANNELS = ( - os.environ.get("USER_PERMISSIONS_FEATURES_CHANNELS", "True").lower() == "true" -) +USER_PERMISSIONS_FEATURES_CHANNELS = os.environ.get('USER_PERMISSIONS_FEATURES_CHANNELS', 'True').lower() == 'true' -USER_PERMISSIONS_FEATURES_API_KEYS = ( - os.environ.get("USER_PERMISSIONS_FEATURES_API_KEYS", "False").lower() == "true" -) +USER_PERMISSIONS_FEATURES_API_KEYS = os.environ.get('USER_PERMISSIONS_FEATURES_API_KEYS', 'False').lower() == 'true' -USER_PERMISSIONS_FEATURES_MEMORIES = ( - os.environ.get("USER_PERMISSIONS_FEATURES_MEMORIES", "True").lower() == "true" -) +USER_PERMISSIONS_FEATURES_MEMORIES = os.environ.get('USER_PERMISSIONS_FEATURES_MEMORIES', 'True').lower() == 'true' -USER_PERMISSIONS_SETTINGS_INTERFACE = ( - os.environ.get("USER_PERMISSIONS_SETTINGS_INTERFACE", "True").lower() == "true" -) +USER_PERMISSIONS_SETTINGS_INTERFACE = os.environ.get('USER_PERMISSIONS_SETTINGS_INTERFACE', 'True').lower() == 'true' DEFAULT_USER_PERMISSIONS = { - "workspace": { - "models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS, - "knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS, - "prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS, - "tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS, - "skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS, - "models_import": USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT, - "models_export": USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT, - "prompts_import": USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT, - "prompts_export": USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT, - "tools_import": USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT, - "tools_export": USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT, + 'workspace': { + 'models': USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS, + 'knowledge': USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS, + 'prompts': USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS, + 'tools': USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS, + 'skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ACCESS, + 'models_import': USER_PERMISSIONS_WORKSPACE_MODELS_IMPORT, + 'models_export': USER_PERMISSIONS_WORKSPACE_MODELS_EXPORT, + 'prompts_import': USER_PERMISSIONS_WORKSPACE_PROMPTS_IMPORT, + 'prompts_export': USER_PERMISSIONS_WORKSPACE_PROMPTS_EXPORT, + 'tools_import': USER_PERMISSIONS_WORKSPACE_TOOLS_IMPORT, + 'tools_export': USER_PERMISSIONS_WORKSPACE_TOOLS_EXPORT, }, - "sharing": { - "models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING, - "public_models": USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING, - "knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING, - "public_knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING, - "prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING, - "public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING, - "tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING, - "public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING, - "skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING, - "public_skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING, - "notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING, - "public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, + 'sharing': { + 'models': USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING, + 'public_models': USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING, + 'knowledge': USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING, + 'public_knowledge': USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING, + 'prompts': USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING, + 'public_prompts': USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING, + 'tools': USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING, + 'public_tools': USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING, + 'skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING, + 'public_skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING, + 'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING, + 'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, }, - "access_grants": { - "allow_users": USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS, + 'access_grants': { + 'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS, }, - "chat": { - "controls": USER_PERMISSIONS_CHAT_CONTROLS, - "valves": USER_PERMISSIONS_CHAT_VALVES, - "system_prompt": USER_PERMISSIONS_CHAT_SYSTEM_PROMPT, - "params": USER_PERMISSIONS_CHAT_PARAMS, - "file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD, - "web_upload": USER_PERMISSIONS_CHAT_WEB_UPLOAD, - "delete": USER_PERMISSIONS_CHAT_DELETE, - "delete_message": USER_PERMISSIONS_CHAT_DELETE_MESSAGE, - "continue_response": USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE, - "regenerate_response": USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE, - "rate_response": USER_PERMISSIONS_CHAT_RATE_RESPONSE, - "edit": USER_PERMISSIONS_CHAT_EDIT, - "share": USER_PERMISSIONS_CHAT_SHARE, - "export": USER_PERMISSIONS_CHAT_EXPORT, - "stt": USER_PERMISSIONS_CHAT_STT, - "tts": USER_PERMISSIONS_CHAT_TTS, - "call": USER_PERMISSIONS_CHAT_CALL, - "multiple_models": USER_PERMISSIONS_CHAT_MULTIPLE_MODELS, - "temporary": USER_PERMISSIONS_CHAT_TEMPORARY, - "temporary_enforced": USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED, + 'chat': { + 'controls': USER_PERMISSIONS_CHAT_CONTROLS, + 'valves': USER_PERMISSIONS_CHAT_VALVES, + 'system_prompt': USER_PERMISSIONS_CHAT_SYSTEM_PROMPT, + 'params': USER_PERMISSIONS_CHAT_PARAMS, + 'file_upload': USER_PERMISSIONS_CHAT_FILE_UPLOAD, + 'web_upload': USER_PERMISSIONS_CHAT_WEB_UPLOAD, + 'delete': USER_PERMISSIONS_CHAT_DELETE, + 'delete_message': USER_PERMISSIONS_CHAT_DELETE_MESSAGE, + 'continue_response': USER_PERMISSIONS_CHAT_CONTINUE_RESPONSE, + 'regenerate_response': USER_PERMISSIONS_CHAT_REGENERATE_RESPONSE, + 'rate_response': USER_PERMISSIONS_CHAT_RATE_RESPONSE, + 'edit': USER_PERMISSIONS_CHAT_EDIT, + 'share': USER_PERMISSIONS_CHAT_SHARE, + 'export': USER_PERMISSIONS_CHAT_EXPORT, + 'stt': USER_PERMISSIONS_CHAT_STT, + 'tts': USER_PERMISSIONS_CHAT_TTS, + 'call': USER_PERMISSIONS_CHAT_CALL, + 'multiple_models': USER_PERMISSIONS_CHAT_MULTIPLE_MODELS, + 'temporary': USER_PERMISSIONS_CHAT_TEMPORARY, + 'temporary_enforced': USER_PERMISSIONS_CHAT_TEMPORARY_ENFORCED, }, - "features": { + 'features': { # General features - "api_keys": USER_PERMISSIONS_FEATURES_API_KEYS, - "notes": USER_PERMISSIONS_FEATURES_NOTES, - "folders": USER_PERMISSIONS_FEATURES_FOLDERS, - "channels": USER_PERMISSIONS_FEATURES_CHANNELS, - "direct_tool_servers": USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, + 'api_keys': USER_PERMISSIONS_FEATURES_API_KEYS, + 'notes': USER_PERMISSIONS_FEATURES_NOTES, + 'folders': USER_PERMISSIONS_FEATURES_FOLDERS, + 'channels': USER_PERMISSIONS_FEATURES_CHANNELS, + 'direct_tool_servers': USER_PERMISSIONS_FEATURES_DIRECT_TOOL_SERVERS, # Chat features - "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH, - "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, - "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, - "memories": USER_PERMISSIONS_FEATURES_MEMORIES, + 'web_search': USER_PERMISSIONS_FEATURES_WEB_SEARCH, + 'image_generation': USER_PERMISSIONS_FEATURES_IMAGE_GENERATION, + 'code_interpreter': USER_PERMISSIONS_FEATURES_CODE_INTERPRETER, + 'memories': USER_PERMISSIONS_FEATURES_MEMORIES, }, - "settings": { - "interface": USER_PERMISSIONS_SETTINGS_INTERFACE, + 'settings': { + 'interface': USER_PERMISSIONS_SETTINGS_INTERFACE, }, } USER_PERMISSIONS = PersistentConfig( - "USER_PERMISSIONS", - "user.permissions", + 'USER_PERMISSIONS', + 'user.permissions', DEFAULT_USER_PERMISSIONS, ) ENABLE_FOLDERS = PersistentConfig( - "ENABLE_FOLDERS", - "folders.enable", - os.environ.get("ENABLE_FOLDERS", "True").lower() == "true", + 'ENABLE_FOLDERS', + 'folders.enable', + os.environ.get('ENABLE_FOLDERS', 'True').lower() == 'true', ) FOLDER_MAX_FILE_COUNT = PersistentConfig( - "FOLDER_MAX_FILE_COUNT", - "folders.max_file_count", - os.environ.get("FOLDER_MAX_FILE_COUNT", ""), + 'FOLDER_MAX_FILE_COUNT', + 'folders.max_file_count', + os.environ.get('FOLDER_MAX_FILE_COUNT', ''), ) ENABLE_CHANNELS = PersistentConfig( - "ENABLE_CHANNELS", - "channels.enable", - os.environ.get("ENABLE_CHANNELS", "False").lower() == "true", + 'ENABLE_CHANNELS', + 'channels.enable', + os.environ.get('ENABLE_CHANNELS', 'False').lower() == 'true', ) ENABLE_NOTES = PersistentConfig( - "ENABLE_NOTES", - "notes.enable", - os.environ.get("ENABLE_NOTES", "True").lower() == "true", + 'ENABLE_NOTES', + 'notes.enable', + os.environ.get('ENABLE_NOTES', 'True').lower() == 'true', ) ENABLE_USER_STATUS = PersistentConfig( - "ENABLE_USER_STATUS", - "users.enable_status", - os.environ.get("ENABLE_USER_STATUS", "True").lower() == "true", + 'ENABLE_USER_STATUS', + 'users.enable_status', + os.environ.get('ENABLE_USER_STATUS', 'True').lower() == 'true', ) ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig( - "ENABLE_EVALUATION_ARENA_MODELS", - "evaluation.arena.enable", - os.environ.get("ENABLE_EVALUATION_ARENA_MODELS", "True").lower() == "true", + 'ENABLE_EVALUATION_ARENA_MODELS', + 'evaluation.arena.enable', + os.environ.get('ENABLE_EVALUATION_ARENA_MODELS', 'True').lower() == 'true', ) EVALUATION_ARENA_MODELS = PersistentConfig( - "EVALUATION_ARENA_MODELS", - "evaluation.arena.models", + 'EVALUATION_ARENA_MODELS', + 'evaluation.arena.models', [], ) DEFAULT_ARENA_MODEL = { - "id": "arena-model", - "name": "Arena Model", - "meta": { - "profile_image_url": "/favicon.png", - "description": "Submit your questions to anonymous AI chatbots and vote on the best response.", - "model_ids": None, + 'id': 'arena-model', + 'name': 'Arena Model', + 'meta': { + 'profile_image_url': '/favicon.png', + 'description': 'Submit your questions to anonymous AI chatbots and vote on the best response.', + 'model_ids': None, }, } -WEBHOOK_URL = PersistentConfig( - "WEBHOOK_URL", "webhook_url", os.environ.get("WEBHOOK_URL", "") -) +WEBHOOK_URL = PersistentConfig('WEBHOOK_URL', 'webhook_url', os.environ.get('WEBHOOK_URL', '')) -ENABLE_ADMIN_EXPORT = os.environ.get("ENABLE_ADMIN_EXPORT", "True").lower() == "true" +ENABLE_ADMIN_EXPORT = os.environ.get('ENABLE_ADMIN_EXPORT', 'True').lower() == 'true' ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS = ( - os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True").lower() == "true" + os.environ.get('ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS', 'True').lower() == 'true' ) BYPASS_ADMIN_ACCESS_CONTROL = ( os.environ.get( - "BYPASS_ADMIN_ACCESS_CONTROL", - os.environ.get("ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS", "True"), + 'BYPASS_ADMIN_ACCESS_CONTROL', + os.environ.get('ENABLE_ADMIN_WORKSPACE_CONTENT_ACCESS', 'True'), ).lower() - == "true" + == 'true' ) -ENABLE_ADMIN_CHAT_ACCESS = ( - os.environ.get("ENABLE_ADMIN_CHAT_ACCESS", "True").lower() == "true" -) +ENABLE_ADMIN_CHAT_ACCESS = os.environ.get('ENABLE_ADMIN_CHAT_ACCESS', 'True').lower() == 'true' -ENABLE_ADMIN_ANALYTICS = ( - os.environ.get("ENABLE_ADMIN_ANALYTICS", "True").lower() == "true" -) +ENABLE_ADMIN_ANALYTICS = os.environ.get('ENABLE_ADMIN_ANALYTICS', 'True').lower() == 'true' ENABLE_COMMUNITY_SHARING = PersistentConfig( - "ENABLE_COMMUNITY_SHARING", - "ui.enable_community_sharing", - os.environ.get("ENABLE_COMMUNITY_SHARING", "True").lower() == "true", + 'ENABLE_COMMUNITY_SHARING', + 'ui.enable_community_sharing', + os.environ.get('ENABLE_COMMUNITY_SHARING', 'True').lower() == 'true', ) ENABLE_MESSAGE_RATING = PersistentConfig( - "ENABLE_MESSAGE_RATING", - "ui.enable_message_rating", - os.environ.get("ENABLE_MESSAGE_RATING", "True").lower() == "true", + 'ENABLE_MESSAGE_RATING', + 'ui.enable_message_rating', + os.environ.get('ENABLE_MESSAGE_RATING', 'True').lower() == 'true', ) ENABLE_USER_WEBHOOKS = PersistentConfig( - "ENABLE_USER_WEBHOOKS", - "ui.enable_user_webhooks", - os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true", + 'ENABLE_USER_WEBHOOKS', + 'ui.enable_user_webhooks', + os.environ.get('ENABLE_USER_WEBHOOKS', 'False').lower() == 'true', ) # FastAPI / AnyIO settings -THREAD_POOL_SIZE = os.getenv("THREAD_POOL_SIZE", None) +THREAD_POOL_SIZE = os.getenv('THREAD_POOL_SIZE', None) if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str): try: THREAD_POOL_SIZE = int(THREAD_POOL_SIZE) except ValueError: - log.warning( - f"THREAD_POOL_SIZE is not a valid integer: {THREAD_POOL_SIZE}. Defaulting to None." - ) + log.warning(f'THREAD_POOL_SIZE is not a valid integer: {THREAD_POOL_SIZE}. Defaulting to None.') THREAD_POOL_SIZE = None @@ -1764,7 +1591,7 @@ def validate_cors_origin(origin): parsed_url = urlparse(origin) # Check if the scheme is either http or https, or a custom scheme - schemes = ["http", "https"] + CORS_ALLOW_CUSTOM_SCHEME + schemes = ['http', 'https'] + CORS_ALLOW_CUSTOM_SCHEME if parsed_url.scheme not in schemes: raise ValueError( f"Invalid scheme in CORS_ALLOW_ORIGIN: '{origin}'. Only 'http' and 'https' and CORS_ALLOW_CUSTOM_SCHEME are allowed." @@ -1780,17 +1607,15 @@ def validate_cors_origin(origin): # To test CORS_ALLOW_ORIGIN locally, you can set something like # CORS_ALLOW_ORIGIN=http://localhost:5173;http://localhost:8080 # in your .env file depending on your frontend port, 5173 in this case. -CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";") +CORS_ALLOW_ORIGIN = os.environ.get('CORS_ALLOW_ORIGIN', '*').split(';') # Allows custom URL schemes (e.g., app://) to be used as origins for CORS. # Useful for local development or desktop clients with schemes like app:// or other custom protocols. # Provide a semicolon-separated list of allowed schemes in the environment variable CORS_ALLOW_CUSTOM_SCHEMES. -CORS_ALLOW_CUSTOM_SCHEME = os.environ.get("CORS_ALLOW_CUSTOM_SCHEME", "").split(";") +CORS_ALLOW_CUSTOM_SCHEME = os.environ.get('CORS_ALLOW_CUSTOM_SCHEME', '').split(';') -if CORS_ALLOW_ORIGIN == ["*"]: - log.warning( - "\n\nWARNING: CORS_ALLOW_ORIGIN IS SET TO '*' - NOT RECOMMENDED FOR PRODUCTION DEPLOYMENTS.\n" - ) +if CORS_ALLOW_ORIGIN == ['*']: + log.warning("\n\nWARNING: CORS_ALLOW_ORIGIN IS SET TO '*' - NOT RECOMMENDED FOR PRODUCTION DEPLOYMENTS.\n") else: # You have to pick between a single wildcard or a list of origins. # Doing both will result in CORS errors in the browser. @@ -1808,24 +1633,24 @@ class BannerModel(BaseModel): try: - banners = json.loads(os.environ.get("WEBUI_BANNERS", "[]")) + banners = json.loads(os.environ.get('WEBUI_BANNERS', '[]')) banners = [BannerModel(**banner) for banner in banners] except Exception as e: - log.exception(f"Error loading WEBUI_BANNERS: {e}") + log.exception(f'Error loading WEBUI_BANNERS: {e}') banners = [] -WEBUI_BANNERS = PersistentConfig("WEBUI_BANNERS", "ui.banners", banners) +WEBUI_BANNERS = PersistentConfig('WEBUI_BANNERS', 'ui.banners', banners) SHOW_ADMIN_DETAILS = PersistentConfig( - "SHOW_ADMIN_DETAILS", - "auth.admin.show", - os.environ.get("SHOW_ADMIN_DETAILS", "true").lower() == "true", + 'SHOW_ADMIN_DETAILS', + 'auth.admin.show', + os.environ.get('SHOW_ADMIN_DETAILS', 'true').lower() == 'true', ) ADMIN_EMAIL = PersistentConfig( - "ADMIN_EMAIL", - "auth.admin.email", - os.environ.get("ADMIN_EMAIL", None), + 'ADMIN_EMAIL', + 'auth.admin.email', + os.environ.get('ADMIN_EMAIL', None), ) #################################### @@ -1834,21 +1659,21 @@ class BannerModel(BaseModel): TASK_MODEL = PersistentConfig( - "TASK_MODEL", - "task.model.default", - os.environ.get("TASK_MODEL", ""), + 'TASK_MODEL', + 'task.model.default', + os.environ.get('TASK_MODEL', ''), ) TASK_MODEL_EXTERNAL = PersistentConfig( - "TASK_MODEL_EXTERNAL", - "task.model.external", - os.environ.get("TASK_MODEL_EXTERNAL", ""), + 'TASK_MODEL_EXTERNAL', + 'task.model.external', + os.environ.get('TASK_MODEL_EXTERNAL', ''), ) TITLE_GENERATION_PROMPT_TEMPLATE = PersistentConfig( - "TITLE_GENERATION_PROMPT_TEMPLATE", - "task.title.prompt_template", - os.environ.get("TITLE_GENERATION_PROMPT_TEMPLATE", ""), + 'TITLE_GENERATION_PROMPT_TEMPLATE', + 'task.title.prompt_template', + os.environ.get('TITLE_GENERATION_PROMPT_TEMPLATE', ''), ) DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -1876,9 +1701,9 @@ class BannerModel(BaseModel): """ TAGS_GENERATION_PROMPT_TEMPLATE = PersistentConfig( - "TAGS_GENERATION_PROMPT_TEMPLATE", - "task.tags.prompt_template", - os.environ.get("TAGS_GENERATION_PROMPT_TEMPLATE", ""), + 'TAGS_GENERATION_PROMPT_TEMPLATE', + 'task.tags.prompt_template', + os.environ.get('TAGS_GENERATION_PROMPT_TEMPLATE', ''), ) DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -1900,9 +1725,9 @@ class BannerModel(BaseModel): """ IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = PersistentConfig( - "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE", - "task.image.prompt_template", - os.environ.get("IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE", ""), + 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE', + 'task.image.prompt_template', + os.environ.get('IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE', ''), ) DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -1926,9 +1751,9 @@ class BannerModel(BaseModel): """ FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = PersistentConfig( - "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE", - "task.follow_up.prompt_template", - os.environ.get("FOLLOW_UP_GENERATION_PROMPT_TEMPLATE", ""), + 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE', + 'task.follow_up.prompt_template', + os.environ.get('FOLLOW_UP_GENERATION_PROMPT_TEMPLATE', ''), ) DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -1948,39 +1773,39 @@ class BannerModel(BaseModel): """ ENABLE_FOLLOW_UP_GENERATION = PersistentConfig( - "ENABLE_FOLLOW_UP_GENERATION", - "task.follow_up.enable", - os.environ.get("ENABLE_FOLLOW_UP_GENERATION", "True").lower() == "true", + 'ENABLE_FOLLOW_UP_GENERATION', + 'task.follow_up.enable', + os.environ.get('ENABLE_FOLLOW_UP_GENERATION', 'True').lower() == 'true', ) ENABLE_TAGS_GENERATION = PersistentConfig( - "ENABLE_TAGS_GENERATION", - "task.tags.enable", - os.environ.get("ENABLE_TAGS_GENERATION", "True").lower() == "true", + 'ENABLE_TAGS_GENERATION', + 'task.tags.enable', + os.environ.get('ENABLE_TAGS_GENERATION', 'True').lower() == 'true', ) ENABLE_TITLE_GENERATION = PersistentConfig( - "ENABLE_TITLE_GENERATION", - "task.title.enable", - os.environ.get("ENABLE_TITLE_GENERATION", "True").lower() == "true", + 'ENABLE_TITLE_GENERATION', + 'task.title.enable', + os.environ.get('ENABLE_TITLE_GENERATION', 'True').lower() == 'true', ) ENABLE_SEARCH_QUERY_GENERATION = PersistentConfig( - "ENABLE_SEARCH_QUERY_GENERATION", - "task.query.search.enable", - os.environ.get("ENABLE_SEARCH_QUERY_GENERATION", "True").lower() == "true", + 'ENABLE_SEARCH_QUERY_GENERATION', + 'task.query.search.enable', + os.environ.get('ENABLE_SEARCH_QUERY_GENERATION', 'True').lower() == 'true', ) ENABLE_RETRIEVAL_QUERY_GENERATION = PersistentConfig( - "ENABLE_RETRIEVAL_QUERY_GENERATION", - "task.query.retrieval.enable", - os.environ.get("ENABLE_RETRIEVAL_QUERY_GENERATION", "True").lower() == "true", + 'ENABLE_RETRIEVAL_QUERY_GENERATION', + 'task.query.retrieval.enable', + os.environ.get('ENABLE_RETRIEVAL_QUERY_GENERATION', 'True').lower() == 'true', ) QUERY_GENERATION_PROMPT_TEMPLATE = PersistentConfig( - "QUERY_GENERATION_PROMPT_TEMPLATE", - "task.query.prompt_template", - os.environ.get("QUERY_GENERATION_PROMPT_TEMPLATE", ""), + 'QUERY_GENERATION_PROMPT_TEMPLATE', + 'task.query.prompt_template', + os.environ.get('QUERY_GENERATION_PROMPT_TEMPLATE', ''), ) DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -2008,21 +1833,21 @@ class BannerModel(BaseModel): """ ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig( - "ENABLE_AUTOCOMPLETE_GENERATION", - "task.autocomplete.enable", - os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true", + 'ENABLE_AUTOCOMPLETE_GENERATION', + 'task.autocomplete.enable', + os.environ.get('ENABLE_AUTOCOMPLETE_GENERATION', 'False').lower() == 'true', ) AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig( - "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH", - "task.autocomplete.input_max_length", - int(os.environ.get("AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH", "-1")), + 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH', + 'task.autocomplete.input_max_length', + int(os.environ.get('AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH', '-1')), ) AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = PersistentConfig( - "AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE", - "task.autocomplete.prompt_template", - os.environ.get("AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE", ""), + 'AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE', + 'task.autocomplete.prompt_template', + os.environ.get('AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE', ''), ) DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = """### Task: @@ -2068,9 +1893,9 @@ class BannerModel(BaseModel): """ VOICE_MODE_PROMPT_TEMPLATE = PersistentConfig( - "VOICE_MODE_PROMPT_TEMPLATE", - "task.voice.prompt_template", - os.environ.get("VOICE_MODE_PROMPT_TEMPLATE", ""), + 'VOICE_MODE_PROMPT_TEMPLATE', + 'task.voice.prompt_template', + os.environ.get('VOICE_MODE_PROMPT_TEMPLATE', ''), ) DEFAULT_VOICE_MODE_PROMPT_TEMPLATE = """You are a friendly, concise voice assistant. @@ -2099,9 +1924,9 @@ class BannerModel(BaseModel): Stay consistent, helpful, and easy to listen to.""" TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = PersistentConfig( - "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE", - "task.tools.prompt_template", - os.environ.get("TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE", ""), + 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE', + 'task.tools.prompt_template', + os.environ.get('TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE', ''), ) DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = """Available Tools: {{TOOLS}} @@ -2142,121 +1967,117 @@ class BannerModel(BaseModel): #################################### ENABLE_CODE_EXECUTION = PersistentConfig( - "ENABLE_CODE_EXECUTION", - "code_execution.enable", - os.environ.get("ENABLE_CODE_EXECUTION", "True").lower() == "true", + 'ENABLE_CODE_EXECUTION', + 'code_execution.enable', + os.environ.get('ENABLE_CODE_EXECUTION', 'True').lower() == 'true', ) CODE_EXECUTION_ENGINE = PersistentConfig( - "CODE_EXECUTION_ENGINE", - "code_execution.engine", - os.environ.get("CODE_EXECUTION_ENGINE", "pyodide"), + 'CODE_EXECUTION_ENGINE', + 'code_execution.engine', + os.environ.get('CODE_EXECUTION_ENGINE', 'pyodide'), ) CODE_EXECUTION_JUPYTER_URL = PersistentConfig( - "CODE_EXECUTION_JUPYTER_URL", - "code_execution.jupyter.url", - os.environ.get("CODE_EXECUTION_JUPYTER_URL", ""), + 'CODE_EXECUTION_JUPYTER_URL', + 'code_execution.jupyter.url', + os.environ.get('CODE_EXECUTION_JUPYTER_URL', ''), ) CODE_EXECUTION_JUPYTER_AUTH = PersistentConfig( - "CODE_EXECUTION_JUPYTER_AUTH", - "code_execution.jupyter.auth", - os.environ.get("CODE_EXECUTION_JUPYTER_AUTH", ""), + 'CODE_EXECUTION_JUPYTER_AUTH', + 'code_execution.jupyter.auth', + os.environ.get('CODE_EXECUTION_JUPYTER_AUTH', ''), ) CODE_EXECUTION_JUPYTER_AUTH_TOKEN = PersistentConfig( - "CODE_EXECUTION_JUPYTER_AUTH_TOKEN", - "code_execution.jupyter.auth_token", - os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_TOKEN", ""), + 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN', + 'code_execution.jupyter.auth_token', + os.environ.get('CODE_EXECUTION_JUPYTER_AUTH_TOKEN', ''), ) CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = PersistentConfig( - "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", - "code_execution.jupyter.auth_password", - os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", ""), + 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD', + 'code_execution.jupyter.auth_password', + os.environ.get('CODE_EXECUTION_JUPYTER_AUTH_PASSWORD', ''), ) CODE_EXECUTION_JUPYTER_TIMEOUT = PersistentConfig( - "CODE_EXECUTION_JUPYTER_TIMEOUT", - "code_execution.jupyter.timeout", - int(os.environ.get("CODE_EXECUTION_JUPYTER_TIMEOUT", "60")), + 'CODE_EXECUTION_JUPYTER_TIMEOUT', + 'code_execution.jupyter.timeout', + int(os.environ.get('CODE_EXECUTION_JUPYTER_TIMEOUT', '60')), ) ENABLE_CODE_INTERPRETER = PersistentConfig( - "ENABLE_CODE_INTERPRETER", - "code_interpreter.enable", - os.environ.get("ENABLE_CODE_INTERPRETER", "True").lower() == "true", + 'ENABLE_CODE_INTERPRETER', + 'code_interpreter.enable', + os.environ.get('ENABLE_CODE_INTERPRETER', 'True').lower() == 'true', ) ENABLE_MEMORIES = PersistentConfig( - "ENABLE_MEMORIES", - "memories.enable", - os.environ.get("ENABLE_MEMORIES", "True").lower() == "true", + 'ENABLE_MEMORIES', + 'memories.enable', + os.environ.get('ENABLE_MEMORIES', 'True').lower() == 'true', ) CODE_INTERPRETER_ENGINE = PersistentConfig( - "CODE_INTERPRETER_ENGINE", - "code_interpreter.engine", - os.environ.get("CODE_INTERPRETER_ENGINE", "pyodide"), + 'CODE_INTERPRETER_ENGINE', + 'code_interpreter.engine', + os.environ.get('CODE_INTERPRETER_ENGINE', 'pyodide'), ) CODE_INTERPRETER_PROMPT_TEMPLATE = PersistentConfig( - "CODE_INTERPRETER_PROMPT_TEMPLATE", - "code_interpreter.prompt_template", - os.environ.get("CODE_INTERPRETER_PROMPT_TEMPLATE", ""), + 'CODE_INTERPRETER_PROMPT_TEMPLATE', + 'code_interpreter.prompt_template', + os.environ.get('CODE_INTERPRETER_PROMPT_TEMPLATE', ''), ) CODE_INTERPRETER_JUPYTER_URL = PersistentConfig( - "CODE_INTERPRETER_JUPYTER_URL", - "code_interpreter.jupyter.url", - os.environ.get( - "CODE_INTERPRETER_JUPYTER_URL", os.environ.get("CODE_EXECUTION_JUPYTER_URL", "") - ), + 'CODE_INTERPRETER_JUPYTER_URL', + 'code_interpreter.jupyter.url', + os.environ.get('CODE_INTERPRETER_JUPYTER_URL', os.environ.get('CODE_EXECUTION_JUPYTER_URL', '')), ) CODE_INTERPRETER_JUPYTER_AUTH = PersistentConfig( - "CODE_INTERPRETER_JUPYTER_AUTH", - "code_interpreter.jupyter.auth", + 'CODE_INTERPRETER_JUPYTER_AUTH', + 'code_interpreter.jupyter.auth', os.environ.get( - "CODE_INTERPRETER_JUPYTER_AUTH", - os.environ.get("CODE_EXECUTION_JUPYTER_AUTH", ""), + 'CODE_INTERPRETER_JUPYTER_AUTH', + os.environ.get('CODE_EXECUTION_JUPYTER_AUTH', ''), ), ) CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = PersistentConfig( - "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", - "code_interpreter.jupyter.auth_token", + 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN', + 'code_interpreter.jupyter.auth_token', os.environ.get( - "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN", - os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_TOKEN", ""), + 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN', + os.environ.get('CODE_EXECUTION_JUPYTER_AUTH_TOKEN', ''), ), ) CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = PersistentConfig( - "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", - "code_interpreter.jupyter.auth_password", + 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD', + 'code_interpreter.jupyter.auth_password', os.environ.get( - "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD", - os.environ.get("CODE_EXECUTION_JUPYTER_AUTH_PASSWORD", ""), + 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD', + os.environ.get('CODE_EXECUTION_JUPYTER_AUTH_PASSWORD', ''), ), ) CODE_INTERPRETER_JUPYTER_TIMEOUT = PersistentConfig( - "CODE_INTERPRETER_JUPYTER_TIMEOUT", - "code_interpreter.jupyter.timeout", + 'CODE_INTERPRETER_JUPYTER_TIMEOUT', + 'code_interpreter.jupyter.timeout', int( os.environ.get( - "CODE_INTERPRETER_JUPYTER_TIMEOUT", - os.environ.get("CODE_EXECUTION_JUPYTER_TIMEOUT", "60"), + 'CODE_INTERPRETER_JUPYTER_TIMEOUT', + os.environ.get('CODE_EXECUTION_JUPYTER_TIMEOUT', '60'), ) ), ) CODE_INTERPRETER_BLOCKED_MODULES = [ - library.strip() - for library in os.environ.get("CODE_INTERPRETER_BLOCKED_MODULES", "").split(",") - if library.strip() + library.strip() for library in os.environ.get('CODE_INTERPRETER_BLOCKED_MODULES', '').split(',') if library.strip() ] DEFAULT_CODE_INTERPRETER_PROMPT = """ @@ -2295,56 +2116,47 @@ class BannerModel(BaseModel): # Vector Database #################################### -VECTOR_DB = os.environ.get("VECTOR_DB", "chroma") +VECTOR_DB = os.environ.get('VECTOR_DB', 'chroma') # Chroma -CHROMA_DATA_PATH = f"{DATA_DIR}/vector_db" +CHROMA_DATA_PATH = f'{DATA_DIR}/vector_db' -if VECTOR_DB == "chroma": +if VECTOR_DB == 'chroma': import chromadb - CHROMA_TENANT = os.environ.get("CHROMA_TENANT", chromadb.DEFAULT_TENANT) - CHROMA_DATABASE = os.environ.get("CHROMA_DATABASE", chromadb.DEFAULT_DATABASE) - CHROMA_HTTP_HOST = os.environ.get("CHROMA_HTTP_HOST", "") - CHROMA_HTTP_PORT = int(os.environ.get("CHROMA_HTTP_PORT", "8000")) - CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get("CHROMA_CLIENT_AUTH_PROVIDER", "") - CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get( - "CHROMA_CLIENT_AUTH_CREDENTIALS", "" - ) + CHROMA_TENANT = os.environ.get('CHROMA_TENANT', chromadb.DEFAULT_TENANT) + CHROMA_DATABASE = os.environ.get('CHROMA_DATABASE', chromadb.DEFAULT_DATABASE) + CHROMA_HTTP_HOST = os.environ.get('CHROMA_HTTP_HOST', '') + CHROMA_HTTP_PORT = int(os.environ.get('CHROMA_HTTP_PORT', '8000')) + CHROMA_CLIENT_AUTH_PROVIDER = os.environ.get('CHROMA_CLIENT_AUTH_PROVIDER', '') + CHROMA_CLIENT_AUTH_CREDENTIALS = os.environ.get('CHROMA_CLIENT_AUTH_CREDENTIALS', '') # Comma-separated list of header=value pairs - CHROMA_HTTP_HEADERS = os.environ.get("CHROMA_HTTP_HEADERS", "") + CHROMA_HTTP_HEADERS = os.environ.get('CHROMA_HTTP_HEADERS', '') if CHROMA_HTTP_HEADERS: - CHROMA_HTTP_HEADERS = dict( - [pair.split("=") for pair in CHROMA_HTTP_HEADERS.split(",")] - ) + CHROMA_HTTP_HEADERS = dict([pair.split('=') for pair in CHROMA_HTTP_HEADERS.split(',')]) else: CHROMA_HTTP_HEADERS = None - CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true" + CHROMA_HTTP_SSL = os.environ.get('CHROMA_HTTP_SSL', 'false').lower() == 'true' # this uses the model defined in the Dockerfile ENV variable. If you dont use docker or docker based deployments such as k8s, the default embedding model will be used (sentence-transformers/all-MiniLM-L6-v2) # MariaDB Vector (mariadb-vector) -MARIADB_VECTOR_DB_URL = os.environ.get("MARIADB_VECTOR_DB_URL", "").strip() +MARIADB_VECTOR_DB_URL = os.environ.get('MARIADB_VECTOR_DB_URL', '').strip() MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int( - os.environ.get("MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536").strip() - or "1536" + os.environ.get('MARIADB_VECTOR_INITIALIZE_MAX_VECTOR_LENGTH', '1536').strip() or '1536' ) # Distance strategy: # - cosine => vec_distance_cosine(...) # - euclidean => vec_distance_euclidean(...) -MARIADB_VECTOR_DISTANCE_STRATEGY = ( - os.environ.get("MARIADB_VECTOR_DISTANCE_STRATEGY", "cosine").strip().lower() -) +MARIADB_VECTOR_DISTANCE_STRATEGY = os.environ.get('MARIADB_VECTOR_DISTANCE_STRATEGY', 'cosine').strip().lower() # HNSW M parameter (MariaDB VECTOR INDEX ... M=) -MARIADB_VECTOR_INDEX_M = int( - os.environ.get("MARIADB_VECTOR_INDEX_M", "8").strip() or "8" -) +MARIADB_VECTOR_INDEX_M = int(os.environ.get('MARIADB_VECTOR_INDEX_M', '8').strip() or '8') # Pooling (MariaDB-Vector) -MARIADB_VECTOR_POOL_SIZE = os.environ.get("MARIADB_VECTOR_POOL_SIZE", None) +MARIADB_VECTOR_POOL_SIZE = os.environ.get('MARIADB_VECTOR_POOL_SIZE', None) if MARIADB_VECTOR_POOL_SIZE != None: try: @@ -2352,9 +2164,9 @@ class BannerModel(BaseModel): except Exception: MARIADB_VECTOR_POOL_SIZE = None -MARIADB_VECTOR_POOL_MAX_OVERFLOW = os.environ.get("MARIADB_VECTOR_POOL_MAX_OVERFLOW", 0) +MARIADB_VECTOR_POOL_MAX_OVERFLOW = os.environ.get('MARIADB_VECTOR_POOL_MAX_OVERFLOW', 0) -if MARIADB_VECTOR_POOL_MAX_OVERFLOW == "": +if MARIADB_VECTOR_POOL_MAX_OVERFLOW == '': MARIADB_VECTOR_POOL_MAX_OVERFLOW = 0 else: try: @@ -2362,9 +2174,9 @@ class BannerModel(BaseModel): except Exception: MARIADB_VECTOR_POOL_MAX_OVERFLOW = 0 -MARIADB_VECTOR_POOL_TIMEOUT = os.environ.get("MARIADB_VECTOR_POOL_TIMEOUT", 30) +MARIADB_VECTOR_POOL_TIMEOUT = os.environ.get('MARIADB_VECTOR_POOL_TIMEOUT', 30) -if MARIADB_VECTOR_POOL_TIMEOUT == "": +if MARIADB_VECTOR_POOL_TIMEOUT == '': MARIADB_VECTOR_POOL_TIMEOUT = 30 else: try: @@ -2372,9 +2184,9 @@ class BannerModel(BaseModel): except Exception: MARIADB_VECTOR_POOL_TIMEOUT = 30 -MARIADB_VECTOR_POOL_RECYCLE = os.environ.get("MARIADB_VECTOR_POOL_RECYCLE", 3600) +MARIADB_VECTOR_POOL_RECYCLE = os.environ.get('MARIADB_VECTOR_POOL_RECYCLE', 3600) -if MARIADB_VECTOR_POOL_RECYCLE == "": +if MARIADB_VECTOR_POOL_RECYCLE == '': MARIADB_VECTOR_POOL_RECYCLE = 3600 else: try: @@ -2383,114 +2195,96 @@ class BannerModel(BaseModel): MARIADB_VECTOR_POOL_RECYCLE = 3600 ENABLE_MARIADB_VECTOR = True -if VECTOR_DB == "mariadb-vector": +if VECTOR_DB == 'mariadb-vector': if not MARIADB_VECTOR_DB_URL: ENABLE_MARIADB_VECTOR = False else: try: parsed = urlparse(MARIADB_VECTOR_DB_URL) - scheme = (parsed.scheme or "").lower() + scheme = (parsed.scheme or '').lower() # Require official driver so VECTOR binds as float32 bytes correctly - if scheme != "mariadb+mariadbconnector": + if scheme != 'mariadb+mariadbconnector': ENABLE_MARIADB_VECTOR = False except Exception: ENABLE_MARIADB_VECTOR = False # Milvus -MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db") -MILVUS_DB = os.environ.get("MILVUS_DB", "default") -MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None) -MILVUS_INDEX_TYPE = os.environ.get("MILVUS_INDEX_TYPE", "HNSW") -MILVUS_METRIC_TYPE = os.environ.get("MILVUS_METRIC_TYPE", "COSINE") -MILVUS_HNSW_M = int(os.environ.get("MILVUS_HNSW_M", "16")) -MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get("MILVUS_HNSW_EFCONSTRUCTION", "100")) -MILVUS_IVF_FLAT_NLIST = int(os.environ.get("MILVUS_IVF_FLAT_NLIST", "128")) -MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get("MILVUS_DISKANN_MAX_DEGREE", "56")) -MILVUS_DISKANN_SEARCH_LIST_SIZE = int( - os.environ.get("MILVUS_DISKANN_SEARCH_LIST_SIZE", "100") -) -ENABLE_MILVUS_MULTITENANCY_MODE = ( - os.environ.get("ENABLE_MILVUS_MULTITENANCY_MODE", "false").lower() == "true" -) +MILVUS_URI = os.environ.get('MILVUS_URI', f'{DATA_DIR}/vector_db/milvus.db') +MILVUS_DB = os.environ.get('MILVUS_DB', 'default') +MILVUS_TOKEN = os.environ.get('MILVUS_TOKEN', None) +MILVUS_INDEX_TYPE = os.environ.get('MILVUS_INDEX_TYPE', 'HNSW') +MILVUS_METRIC_TYPE = os.environ.get('MILVUS_METRIC_TYPE', 'COSINE') +MILVUS_HNSW_M = int(os.environ.get('MILVUS_HNSW_M', '16')) +MILVUS_HNSW_EFCONSTRUCTION = int(os.environ.get('MILVUS_HNSW_EFCONSTRUCTION', '100')) +MILVUS_IVF_FLAT_NLIST = int(os.environ.get('MILVUS_IVF_FLAT_NLIST', '128')) +MILVUS_DISKANN_MAX_DEGREE = int(os.environ.get('MILVUS_DISKANN_MAX_DEGREE', '56')) +MILVUS_DISKANN_SEARCH_LIST_SIZE = int(os.environ.get('MILVUS_DISKANN_SEARCH_LIST_SIZE', '100')) +ENABLE_MILVUS_MULTITENANCY_MODE = os.environ.get('ENABLE_MILVUS_MULTITENANCY_MODE', 'false').lower() == 'true' # Hyphens not allowed, need to use underscores in collection names -MILVUS_COLLECTION_PREFIX = os.environ.get("MILVUS_COLLECTION_PREFIX", "open_webui") +MILVUS_COLLECTION_PREFIX = os.environ.get('MILVUS_COLLECTION_PREFIX', 'open_webui') # Qdrant -QDRANT_URI = os.environ.get("QDRANT_URI", None) -QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None) -QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true" -QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "false").lower() == "true" -QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334")) -QDRANT_TIMEOUT = int(os.environ.get("QDRANT_TIMEOUT", "5")) -QDRANT_HNSW_M = int(os.environ.get("QDRANT_HNSW_M", "16")) -ENABLE_QDRANT_MULTITENANCY_MODE = ( - os.environ.get("ENABLE_QDRANT_MULTITENANCY_MODE", "true").lower() == "true" -) -QDRANT_COLLECTION_PREFIX = os.environ.get("QDRANT_COLLECTION_PREFIX", "open-webui") - -WEAVIATE_HTTP_HOST = os.environ.get("WEAVIATE_HTTP_HOST", "") -WEAVIATE_GRPC_HOST = os.environ.get("WEAVIATE_GRPC_HOST", "") -WEAVIATE_HTTP_PORT = int(os.environ.get("WEAVIATE_HTTP_PORT", "8080")) -WEAVIATE_GRPC_PORT = int(os.environ.get("WEAVIATE_GRPC_PORT", "50051")) -WEAVIATE_API_KEY = os.environ.get("WEAVIATE_API_KEY") -WEAVIATE_HTTP_SECURE = os.environ.get("WEAVIATE_HTTP_SECURE", "false").lower() == "true" -WEAVIATE_GRPC_SECURE = os.environ.get("WEAVIATE_GRPC_SECURE", "false").lower() == "true" -WEAVIATE_SKIP_INIT_CHECKS = ( - os.environ.get("WEAVIATE_SKIP_INIT_CHECKS", "false").lower() == "true" -) +QDRANT_URI = os.environ.get('QDRANT_URI', None) +QDRANT_API_KEY = os.environ.get('QDRANT_API_KEY', None) +QDRANT_ON_DISK = os.environ.get('QDRANT_ON_DISK', 'false').lower() == 'true' +QDRANT_PREFER_GRPC = os.environ.get('QDRANT_PREFER_GRPC', 'false').lower() == 'true' +QDRANT_GRPC_PORT = int(os.environ.get('QDRANT_GRPC_PORT', '6334')) +QDRANT_TIMEOUT = int(os.environ.get('QDRANT_TIMEOUT', '5')) +QDRANT_HNSW_M = int(os.environ.get('QDRANT_HNSW_M', '16')) +ENABLE_QDRANT_MULTITENANCY_MODE = os.environ.get('ENABLE_QDRANT_MULTITENANCY_MODE', 'true').lower() == 'true' +QDRANT_COLLECTION_PREFIX = os.environ.get('QDRANT_COLLECTION_PREFIX', 'open-webui') + +WEAVIATE_HTTP_HOST = os.environ.get('WEAVIATE_HTTP_HOST', '') +WEAVIATE_GRPC_HOST = os.environ.get('WEAVIATE_GRPC_HOST', '') +WEAVIATE_HTTP_PORT = int(os.environ.get('WEAVIATE_HTTP_PORT', '8080')) +WEAVIATE_GRPC_PORT = int(os.environ.get('WEAVIATE_GRPC_PORT', '50051')) +WEAVIATE_API_KEY = os.environ.get('WEAVIATE_API_KEY') +WEAVIATE_HTTP_SECURE = os.environ.get('WEAVIATE_HTTP_SECURE', 'false').lower() == 'true' +WEAVIATE_GRPC_SECURE = os.environ.get('WEAVIATE_GRPC_SECURE', 'false').lower() == 'true' +WEAVIATE_SKIP_INIT_CHECKS = os.environ.get('WEAVIATE_SKIP_INIT_CHECKS', 'false').lower() == 'true' # OpenSearch -OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200") -OPENSEARCH_SSL = os.environ.get("OPENSEARCH_SSL", "true").lower() == "true" -OPENSEARCH_CERT_VERIFY = ( - os.environ.get("OPENSEARCH_CERT_VERIFY", "false").lower() == "true" -) -OPENSEARCH_USERNAME = os.environ.get("OPENSEARCH_USERNAME", None) -OPENSEARCH_PASSWORD = os.environ.get("OPENSEARCH_PASSWORD", None) +OPENSEARCH_URI = os.environ.get('OPENSEARCH_URI', 'https://localhost:9200') +OPENSEARCH_SSL = os.environ.get('OPENSEARCH_SSL', 'true').lower() == 'true' +OPENSEARCH_CERT_VERIFY = os.environ.get('OPENSEARCH_CERT_VERIFY', 'false').lower() == 'true' +OPENSEARCH_USERNAME = os.environ.get('OPENSEARCH_USERNAME', None) +OPENSEARCH_PASSWORD = os.environ.get('OPENSEARCH_PASSWORD', None) # ElasticSearch -ELASTICSEARCH_URL = os.environ.get("ELASTICSEARCH_URL", "https://localhost:9200") -ELASTICSEARCH_CA_CERTS = os.environ.get("ELASTICSEARCH_CA_CERTS", None) -ELASTICSEARCH_API_KEY = os.environ.get("ELASTICSEARCH_API_KEY", None) -ELASTICSEARCH_USERNAME = os.environ.get("ELASTICSEARCH_USERNAME", None) -ELASTICSEARCH_PASSWORD = os.environ.get("ELASTICSEARCH_PASSWORD", None) -ELASTICSEARCH_CLOUD_ID = os.environ.get("ELASTICSEARCH_CLOUD_ID", None) -SSL_ASSERT_FINGERPRINT = os.environ.get("SSL_ASSERT_FINGERPRINT", None) -ELASTICSEARCH_INDEX_PREFIX = os.environ.get( - "ELASTICSEARCH_INDEX_PREFIX", "open_webui_collections" -) +ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL', 'https://localhost:9200') +ELASTICSEARCH_CA_CERTS = os.environ.get('ELASTICSEARCH_CA_CERTS', None) +ELASTICSEARCH_API_KEY = os.environ.get('ELASTICSEARCH_API_KEY', None) +ELASTICSEARCH_USERNAME = os.environ.get('ELASTICSEARCH_USERNAME', None) +ELASTICSEARCH_PASSWORD = os.environ.get('ELASTICSEARCH_PASSWORD', None) +ELASTICSEARCH_CLOUD_ID = os.environ.get('ELASTICSEARCH_CLOUD_ID', None) +SSL_ASSERT_FINGERPRINT = os.environ.get('SSL_ASSERT_FINGERPRINT', None) +ELASTICSEARCH_INDEX_PREFIX = os.environ.get('ELASTICSEARCH_INDEX_PREFIX', 'open_webui_collections') # Pgvector -PGVECTOR_DB_URL = os.environ.get("PGVECTOR_DB_URL", DATABASE_URL) -if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"): +PGVECTOR_DB_URL = os.environ.get('PGVECTOR_DB_URL', DATABASE_URL) +if VECTOR_DB == 'pgvector' and not PGVECTOR_DB_URL.startswith('postgres'): raise ValueError( - "Pgvector requires setting PGVECTOR_DB_URL or using Postgres with vector extension as the primary database." + 'Pgvector requires setting PGVECTOR_DB_URL or using Postgres with vector extension as the primary database.' ) -PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int( - os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536") -) +PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(os.environ.get('PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH', '1536')) -PGVECTOR_USE_HALFVEC = os.getenv("PGVECTOR_USE_HALFVEC", "false").lower() == "true" +PGVECTOR_USE_HALFVEC = os.getenv('PGVECTOR_USE_HALFVEC', 'false').lower() == 'true' if PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH > 2000 and not PGVECTOR_USE_HALFVEC: raise ValueError( - "PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH is set to " - f"{PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH}, which exceeds the 2000 dimension limit of the " + 'PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH is set to ' + f'{PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH}, which exceeds the 2000 dimension limit of the ' "'vector' type. Set PGVECTOR_USE_HALFVEC=true to enable the 'halfvec' " - "type required for high-dimensional embeddings." + 'type required for high-dimensional embeddings.' ) -PGVECTOR_CREATE_EXTENSION = ( - os.getenv("PGVECTOR_CREATE_EXTENSION", "true").lower() == "true" -) -PGVECTOR_PGCRYPTO = os.getenv("PGVECTOR_PGCRYPTO", "false").lower() == "true" -PGVECTOR_PGCRYPTO_KEY = os.getenv("PGVECTOR_PGCRYPTO_KEY", None) +PGVECTOR_CREATE_EXTENSION = os.getenv('PGVECTOR_CREATE_EXTENSION', 'true').lower() == 'true' +PGVECTOR_PGCRYPTO = os.getenv('PGVECTOR_PGCRYPTO', 'false').lower() == 'true' +PGVECTOR_PGCRYPTO_KEY = os.getenv('PGVECTOR_PGCRYPTO_KEY', None) if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY: - raise ValueError( - "PGVECTOR_PGCRYPTO is enabled but PGVECTOR_PGCRYPTO_KEY is not set. Please provide a valid key." - ) + raise ValueError('PGVECTOR_PGCRYPTO is enabled but PGVECTOR_PGCRYPTO_KEY is not set. Please provide a valid key.') -PGVECTOR_POOL_SIZE = os.environ.get("PGVECTOR_POOL_SIZE", None) +PGVECTOR_POOL_SIZE = os.environ.get('PGVECTOR_POOL_SIZE', None) if PGVECTOR_POOL_SIZE != None: try: @@ -2498,9 +2292,9 @@ class BannerModel(BaseModel): except Exception: PGVECTOR_POOL_SIZE = None -PGVECTOR_POOL_MAX_OVERFLOW = os.environ.get("PGVECTOR_POOL_MAX_OVERFLOW", 0) +PGVECTOR_POOL_MAX_OVERFLOW = os.environ.get('PGVECTOR_POOL_MAX_OVERFLOW', 0) -if PGVECTOR_POOL_MAX_OVERFLOW == "": +if PGVECTOR_POOL_MAX_OVERFLOW == '': PGVECTOR_POOL_MAX_OVERFLOW = 0 else: try: @@ -2508,9 +2302,9 @@ class BannerModel(BaseModel): except Exception: PGVECTOR_POOL_MAX_OVERFLOW = 0 -PGVECTOR_POOL_TIMEOUT = os.environ.get("PGVECTOR_POOL_TIMEOUT", 30) +PGVECTOR_POOL_TIMEOUT = os.environ.get('PGVECTOR_POOL_TIMEOUT', 30) -if PGVECTOR_POOL_TIMEOUT == "": +if PGVECTOR_POOL_TIMEOUT == '': PGVECTOR_POOL_TIMEOUT = 30 else: try: @@ -2518,9 +2312,9 @@ class BannerModel(BaseModel): except Exception: PGVECTOR_POOL_TIMEOUT = 30 -PGVECTOR_POOL_RECYCLE = os.environ.get("PGVECTOR_POOL_RECYCLE", 3600) +PGVECTOR_POOL_RECYCLE = os.environ.get('PGVECTOR_POOL_RECYCLE', 3600) -if PGVECTOR_POOL_RECYCLE == "": +if PGVECTOR_POOL_RECYCLE == '': PGVECTOR_POOL_RECYCLE = 3600 else: try: @@ -2528,13 +2322,13 @@ class BannerModel(BaseModel): except Exception: PGVECTOR_POOL_RECYCLE = 3600 -PGVECTOR_INDEX_METHOD = os.getenv("PGVECTOR_INDEX_METHOD", "").strip().lower() -if PGVECTOR_INDEX_METHOD not in ("ivfflat", "hnsw", ""): - PGVECTOR_INDEX_METHOD = "" +PGVECTOR_INDEX_METHOD = os.getenv('PGVECTOR_INDEX_METHOD', '').strip().lower() +if PGVECTOR_INDEX_METHOD not in ('ivfflat', 'hnsw', ''): + PGVECTOR_INDEX_METHOD = '' -PGVECTOR_HNSW_M = os.environ.get("PGVECTOR_HNSW_M", 16) +PGVECTOR_HNSW_M = os.environ.get('PGVECTOR_HNSW_M', 16) -if PGVECTOR_HNSW_M == "": +if PGVECTOR_HNSW_M == '': PGVECTOR_HNSW_M = 16 else: try: @@ -2542,9 +2336,9 @@ class BannerModel(BaseModel): except Exception: PGVECTOR_HNSW_M = 16 -PGVECTOR_HNSW_EF_CONSTRUCTION = os.environ.get("PGVECTOR_HNSW_EF_CONSTRUCTION", 64) +PGVECTOR_HNSW_EF_CONSTRUCTION = os.environ.get('PGVECTOR_HNSW_EF_CONSTRUCTION', 64) -if PGVECTOR_HNSW_EF_CONSTRUCTION == "": +if PGVECTOR_HNSW_EF_CONSTRUCTION == '': PGVECTOR_HNSW_EF_CONSTRUCTION = 64 else: try: @@ -2552,9 +2346,9 @@ class BannerModel(BaseModel): except Exception: PGVECTOR_HNSW_EF_CONSTRUCTION = 64 -PGVECTOR_IVFFLAT_LISTS = os.environ.get("PGVECTOR_IVFFLAT_LISTS", 100) +PGVECTOR_IVFFLAT_LISTS = os.environ.get('PGVECTOR_IVFFLAT_LISTS', 100) -if PGVECTOR_IVFFLAT_LISTS == "": +if PGVECTOR_IVFFLAT_LISTS == '': PGVECTOR_IVFFLAT_LISTS = 100 else: try: @@ -2563,13 +2357,11 @@ class BannerModel(BaseModel): PGVECTOR_IVFFLAT_LISTS = 100 # openGauss -OPENGAUSS_DB_URL = os.environ.get("OPENGAUSS_DB_URL", DATABASE_URL) +OPENGAUSS_DB_URL = os.environ.get('OPENGAUSS_DB_URL', DATABASE_URL) -OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH = int( - os.environ.get("OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH", "1536") -) +OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH = int(os.environ.get('OPENGAUSS_INITIALIZE_MAX_VECTOR_LENGTH', '1536')) -OPENGAUSS_POOL_SIZE = os.environ.get("OPENGAUSS_POOL_SIZE", None) +OPENGAUSS_POOL_SIZE = os.environ.get('OPENGAUSS_POOL_SIZE', None) if OPENGAUSS_POOL_SIZE != None: try: @@ -2577,9 +2369,9 @@ class BannerModel(BaseModel): except Exception: OPENGAUSS_POOL_SIZE = None -OPENGAUSS_POOL_MAX_OVERFLOW = os.environ.get("OPENGAUSS_POOL_MAX_OVERFLOW", 0) +OPENGAUSS_POOL_MAX_OVERFLOW = os.environ.get('OPENGAUSS_POOL_MAX_OVERFLOW', 0) -if OPENGAUSS_POOL_MAX_OVERFLOW == "": +if OPENGAUSS_POOL_MAX_OVERFLOW == '': OPENGAUSS_POOL_MAX_OVERFLOW = 0 else: try: @@ -2587,9 +2379,9 @@ class BannerModel(BaseModel): except Exception: OPENGAUSS_POOL_MAX_OVERFLOW = 0 -OPENGAUSS_POOL_TIMEOUT = os.environ.get("OPENGAUSS_POOL_TIMEOUT", 30) +OPENGAUSS_POOL_TIMEOUT = os.environ.get('OPENGAUSS_POOL_TIMEOUT', 30) -if OPENGAUSS_POOL_TIMEOUT == "": +if OPENGAUSS_POOL_TIMEOUT == '': OPENGAUSS_POOL_TIMEOUT = 30 else: try: @@ -2597,9 +2389,9 @@ class BannerModel(BaseModel): except Exception: OPENGAUSS_POOL_TIMEOUT = 30 -OPENGAUSS_POOL_RECYCLE = os.environ.get("OPENGAUSS_POOL_RECYCLE", 3600) +OPENGAUSS_POOL_RECYCLE = os.environ.get('OPENGAUSS_POOL_RECYCLE', 3600) -if OPENGAUSS_POOL_RECYCLE == "": +if OPENGAUSS_POOL_RECYCLE == '': OPENGAUSS_POOL_RECYCLE = 3600 else: try: @@ -2608,42 +2400,40 @@ class BannerModel(BaseModel): OPENGAUSS_POOL_RECYCLE = 3600 # Pinecone -PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None) -PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None) -PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "open-webui-index") -PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536)) # or 3072, 1024, 768 -PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine") -PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws") # or "gcp" or "azure" +PINECONE_API_KEY = os.environ.get('PINECONE_API_KEY', None) +PINECONE_ENVIRONMENT = os.environ.get('PINECONE_ENVIRONMENT', None) +PINECONE_INDEX_NAME = os.getenv('PINECONE_INDEX_NAME', 'open-webui-index') +PINECONE_DIMENSION = int(os.getenv('PINECONE_DIMENSION', 1536)) # or 3072, 1024, 768 +PINECONE_METRIC = os.getenv('PINECONE_METRIC', 'cosine') +PINECONE_CLOUD = os.getenv('PINECONE_CLOUD', 'aws') # or "gcp" or "azure" # ORACLE23AI (Oracle23ai Vector Search) -ORACLE_DB_USE_WALLET = os.environ.get("ORACLE_DB_USE_WALLET", "false").lower() == "true" -ORACLE_DB_USER = os.environ.get("ORACLE_DB_USER", None) # -ORACLE_DB_PASSWORD = os.environ.get("ORACLE_DB_PASSWORD", None) # -ORACLE_DB_DSN = os.environ.get("ORACLE_DB_DSN", None) # -ORACLE_WALLET_DIR = os.environ.get("ORACLE_WALLET_DIR", None) -ORACLE_WALLET_PASSWORD = os.environ.get("ORACLE_WALLET_PASSWORD", None) -ORACLE_VECTOR_LENGTH = os.environ.get("ORACLE_VECTOR_LENGTH", 768) +ORACLE_DB_USE_WALLET = os.environ.get('ORACLE_DB_USE_WALLET', 'false').lower() == 'true' +ORACLE_DB_USER = os.environ.get('ORACLE_DB_USER', None) # +ORACLE_DB_PASSWORD = os.environ.get('ORACLE_DB_PASSWORD', None) # +ORACLE_DB_DSN = os.environ.get('ORACLE_DB_DSN', None) # +ORACLE_WALLET_DIR = os.environ.get('ORACLE_WALLET_DIR', None) +ORACLE_WALLET_PASSWORD = os.environ.get('ORACLE_WALLET_PASSWORD', None) +ORACLE_VECTOR_LENGTH = os.environ.get('ORACLE_VECTOR_LENGTH', 768) -ORACLE_DB_POOL_MIN = int(os.environ.get("ORACLE_DB_POOL_MIN", 2)) -ORACLE_DB_POOL_MAX = int(os.environ.get("ORACLE_DB_POOL_MAX", 10)) -ORACLE_DB_POOL_INCREMENT = int(os.environ.get("ORACLE_DB_POOL_INCREMENT", 1)) +ORACLE_DB_POOL_MIN = int(os.environ.get('ORACLE_DB_POOL_MIN', 2)) +ORACLE_DB_POOL_MAX = int(os.environ.get('ORACLE_DB_POOL_MAX', 10)) +ORACLE_DB_POOL_INCREMENT = int(os.environ.get('ORACLE_DB_POOL_INCREMENT', 1)) -if VECTOR_DB == "oracle23ai": +if VECTOR_DB == 'oracle23ai': if not ORACLE_DB_USER or not ORACLE_DB_PASSWORD or not ORACLE_DB_DSN: - raise ValueError( - "Oracle23ai requires setting ORACLE_DB_USER, ORACLE_DB_PASSWORD, and ORACLE_DB_DSN." - ) + raise ValueError('Oracle23ai requires setting ORACLE_DB_USER, ORACLE_DB_PASSWORD, and ORACLE_DB_DSN.') if ORACLE_DB_USE_WALLET and (not ORACLE_WALLET_DIR or not ORACLE_WALLET_PASSWORD): raise ValueError( - "Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication." + 'Oracle23ai requires setting ORACLE_WALLET_DIR and ORACLE_WALLET_PASSWORD when using wallet authentication.' ) -log.info(f"VECTOR_DB: {VECTOR_DB}") +log.info(f'VECTOR_DB: {VECTOR_DB}') # S3 Vector -S3_VECTOR_BUCKET_NAME = os.environ.get("S3_VECTOR_BUCKET_NAME", None) -S3_VECTOR_REGION = os.environ.get("S3_VECTOR_REGION", None) +S3_VECTOR_BUCKET_NAME = os.environ.get('S3_VECTOR_BUCKET_NAME', None) +S3_VECTOR_REGION = os.environ.get('S3_VECTOR_REGION', None) #################################### # Information Retrieval (RAG) @@ -2652,469 +2442,428 @@ class BannerModel(BaseModel): # If configured, Google Drive will be available as an upload option. ENABLE_GOOGLE_DRIVE_INTEGRATION = PersistentConfig( - "ENABLE_GOOGLE_DRIVE_INTEGRATION", - "google_drive.enable", - os.getenv("ENABLE_GOOGLE_DRIVE_INTEGRATION", "False").lower() == "true", + 'ENABLE_GOOGLE_DRIVE_INTEGRATION', + 'google_drive.enable', + os.getenv('ENABLE_GOOGLE_DRIVE_INTEGRATION', 'False').lower() == 'true', ) GOOGLE_DRIVE_CLIENT_ID = PersistentConfig( - "GOOGLE_DRIVE_CLIENT_ID", - "google_drive.client_id", - os.environ.get("GOOGLE_DRIVE_CLIENT_ID", ""), + 'GOOGLE_DRIVE_CLIENT_ID', + 'google_drive.client_id', + os.environ.get('GOOGLE_DRIVE_CLIENT_ID', ''), ) GOOGLE_DRIVE_API_KEY = PersistentConfig( - "GOOGLE_DRIVE_API_KEY", - "google_drive.api_key", - os.environ.get("GOOGLE_DRIVE_API_KEY", ""), + 'GOOGLE_DRIVE_API_KEY', + 'google_drive.api_key', + os.environ.get('GOOGLE_DRIVE_API_KEY', ''), ) ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig( - "ENABLE_ONEDRIVE_INTEGRATION", - "onedrive.enable", - os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true", + 'ENABLE_ONEDRIVE_INTEGRATION', + 'onedrive.enable', + os.getenv('ENABLE_ONEDRIVE_INTEGRATION', 'False').lower() == 'true', ) -ENABLE_ONEDRIVE_PERSONAL = ( - os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true" -) -ENABLE_ONEDRIVE_BUSINESS = ( - os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true" -) +ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true' +ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true' -ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "") -ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get( - "ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID -) -ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get( - "ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID -) +ONEDRIVE_CLIENT_ID = os.environ.get('ONEDRIVE_CLIENT_ID', '') +ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get('ONEDRIVE_CLIENT_ID_PERSONAL', ONEDRIVE_CLIENT_ID) +ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get('ONEDRIVE_CLIENT_ID_BUSINESS', ONEDRIVE_CLIENT_ID) ONEDRIVE_SHAREPOINT_URL = PersistentConfig( - "ONEDRIVE_SHAREPOINT_URL", - "onedrive.sharepoint_url", - os.environ.get("ONEDRIVE_SHAREPOINT_URL", ""), + 'ONEDRIVE_SHAREPOINT_URL', + 'onedrive.sharepoint_url', + os.environ.get('ONEDRIVE_SHAREPOINT_URL', ''), ) ONEDRIVE_SHAREPOINT_TENANT_ID = PersistentConfig( - "ONEDRIVE_SHAREPOINT_TENANT_ID", - "onedrive.sharepoint_tenant_id", - os.environ.get("ONEDRIVE_SHAREPOINT_TENANT_ID", ""), + 'ONEDRIVE_SHAREPOINT_TENANT_ID', + 'onedrive.sharepoint_tenant_id', + os.environ.get('ONEDRIVE_SHAREPOINT_TENANT_ID', ''), ) # RAG Content Extraction CONTENT_EXTRACTION_ENGINE = PersistentConfig( - "CONTENT_EXTRACTION_ENGINE", - "rag.CONTENT_EXTRACTION_ENGINE", - os.environ.get("CONTENT_EXTRACTION_ENGINE", "").lower(), + 'CONTENT_EXTRACTION_ENGINE', + 'rag.CONTENT_EXTRACTION_ENGINE', + os.environ.get('CONTENT_EXTRACTION_ENGINE', '').lower(), ) DATALAB_MARKER_API_KEY = PersistentConfig( - "DATALAB_MARKER_API_KEY", - "rag.datalab_marker_api_key", - os.environ.get("DATALAB_MARKER_API_KEY", ""), + 'DATALAB_MARKER_API_KEY', + 'rag.datalab_marker_api_key', + os.environ.get('DATALAB_MARKER_API_KEY', ''), ) DATALAB_MARKER_API_BASE_URL = PersistentConfig( - "DATALAB_MARKER_API_BASE_URL", - "rag.datalab_marker_api_base_url", - os.environ.get("DATALAB_MARKER_API_BASE_URL", ""), + 'DATALAB_MARKER_API_BASE_URL', + 'rag.datalab_marker_api_base_url', + os.environ.get('DATALAB_MARKER_API_BASE_URL', ''), ) DATALAB_MARKER_ADDITIONAL_CONFIG = PersistentConfig( - "DATALAB_MARKER_ADDITIONAL_CONFIG", - "rag.datalab_marker_additional_config", - os.environ.get("DATALAB_MARKER_ADDITIONAL_CONFIG", ""), + 'DATALAB_MARKER_ADDITIONAL_CONFIG', + 'rag.datalab_marker_additional_config', + os.environ.get('DATALAB_MARKER_ADDITIONAL_CONFIG', ''), ) DATALAB_MARKER_USE_LLM = PersistentConfig( - "DATALAB_MARKER_USE_LLM", - "rag.DATALAB_MARKER_USE_LLM", - os.environ.get("DATALAB_MARKER_USE_LLM", "false").lower() == "true", + 'DATALAB_MARKER_USE_LLM', + 'rag.DATALAB_MARKER_USE_LLM', + os.environ.get('DATALAB_MARKER_USE_LLM', 'false').lower() == 'true', ) DATALAB_MARKER_SKIP_CACHE = PersistentConfig( - "DATALAB_MARKER_SKIP_CACHE", - "rag.datalab_marker_skip_cache", - os.environ.get("DATALAB_MARKER_SKIP_CACHE", "false").lower() == "true", + 'DATALAB_MARKER_SKIP_CACHE', + 'rag.datalab_marker_skip_cache', + os.environ.get('DATALAB_MARKER_SKIP_CACHE', 'false').lower() == 'true', ) DATALAB_MARKER_FORCE_OCR = PersistentConfig( - "DATALAB_MARKER_FORCE_OCR", - "rag.datalab_marker_force_ocr", - os.environ.get("DATALAB_MARKER_FORCE_OCR", "false").lower() == "true", + 'DATALAB_MARKER_FORCE_OCR', + 'rag.datalab_marker_force_ocr', + os.environ.get('DATALAB_MARKER_FORCE_OCR', 'false').lower() == 'true', ) DATALAB_MARKER_PAGINATE = PersistentConfig( - "DATALAB_MARKER_PAGINATE", - "rag.datalab_marker_paginate", - os.environ.get("DATALAB_MARKER_PAGINATE", "false").lower() == "true", + 'DATALAB_MARKER_PAGINATE', + 'rag.datalab_marker_paginate', + os.environ.get('DATALAB_MARKER_PAGINATE', 'false').lower() == 'true', ) DATALAB_MARKER_STRIP_EXISTING_OCR = PersistentConfig( - "DATALAB_MARKER_STRIP_EXISTING_OCR", - "rag.datalab_marker_strip_existing_ocr", - os.environ.get("DATALAB_MARKER_STRIP_EXISTING_OCR", "false").lower() == "true", + 'DATALAB_MARKER_STRIP_EXISTING_OCR', + 'rag.datalab_marker_strip_existing_ocr', + os.environ.get('DATALAB_MARKER_STRIP_EXISTING_OCR', 'false').lower() == 'true', ) DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = PersistentConfig( - "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", - "rag.datalab_marker_disable_image_extraction", - os.environ.get("DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", "false").lower() - == "true", + 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION', + 'rag.datalab_marker_disable_image_extraction', + os.environ.get('DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION', 'false').lower() == 'true', ) DATALAB_MARKER_FORMAT_LINES = PersistentConfig( - "DATALAB_MARKER_FORMAT_LINES", - "rag.datalab_marker_format_lines", - os.environ.get("DATALAB_MARKER_FORMAT_LINES", "false").lower() == "true", + 'DATALAB_MARKER_FORMAT_LINES', + 'rag.datalab_marker_format_lines', + os.environ.get('DATALAB_MARKER_FORMAT_LINES', 'false').lower() == 'true', ) DATALAB_MARKER_OUTPUT_FORMAT = PersistentConfig( - "DATALAB_MARKER_OUTPUT_FORMAT", - "rag.datalab_marker_output_format", - os.environ.get("DATALAB_MARKER_OUTPUT_FORMAT", "markdown"), + 'DATALAB_MARKER_OUTPUT_FORMAT', + 'rag.datalab_marker_output_format', + os.environ.get('DATALAB_MARKER_OUTPUT_FORMAT', 'markdown'), ) MINERU_API_MODE = PersistentConfig( - "MINERU_API_MODE", - "rag.mineru_api_mode", - os.environ.get("MINERU_API_MODE", "local"), # "local" or "cloud" + 'MINERU_API_MODE', + 'rag.mineru_api_mode', + os.environ.get('MINERU_API_MODE', 'local'), # "local" or "cloud" ) MINERU_API_URL = PersistentConfig( - "MINERU_API_URL", - "rag.mineru_api_url", - os.environ.get("MINERU_API_URL", "http://localhost:8000"), + 'MINERU_API_URL', + 'rag.mineru_api_url', + os.environ.get('MINERU_API_URL', 'http://localhost:8000'), ) MINERU_API_TIMEOUT = PersistentConfig( - "MINERU_API_TIMEOUT", - "rag.mineru_api_timeout", - os.environ.get("MINERU_API_TIMEOUT", "300"), + 'MINERU_API_TIMEOUT', + 'rag.mineru_api_timeout', + os.environ.get('MINERU_API_TIMEOUT', '300'), ) MINERU_API_KEY = PersistentConfig( - "MINERU_API_KEY", - "rag.mineru_api_key", - os.environ.get("MINERU_API_KEY", ""), + 'MINERU_API_KEY', + 'rag.mineru_api_key', + os.environ.get('MINERU_API_KEY', ''), ) -mineru_params = os.getenv("MINERU_PARAMS", "") +mineru_params = os.getenv('MINERU_PARAMS', '') try: mineru_params = json.loads(mineru_params) except json.JSONDecodeError: mineru_params = {} MINERU_PARAMS = PersistentConfig( - "MINERU_PARAMS", - "rag.mineru_params", + 'MINERU_PARAMS', + 'rag.mineru_params', mineru_params, ) EXTERNAL_DOCUMENT_LOADER_URL = PersistentConfig( - "EXTERNAL_DOCUMENT_LOADER_URL", - "rag.external_document_loader_url", - os.environ.get("EXTERNAL_DOCUMENT_LOADER_URL", ""), + 'EXTERNAL_DOCUMENT_LOADER_URL', + 'rag.external_document_loader_url', + os.environ.get('EXTERNAL_DOCUMENT_LOADER_URL', ''), ) EXTERNAL_DOCUMENT_LOADER_API_KEY = PersistentConfig( - "EXTERNAL_DOCUMENT_LOADER_API_KEY", - "rag.external_document_loader_api_key", - os.environ.get("EXTERNAL_DOCUMENT_LOADER_API_KEY", ""), + 'EXTERNAL_DOCUMENT_LOADER_API_KEY', + 'rag.external_document_loader_api_key', + os.environ.get('EXTERNAL_DOCUMENT_LOADER_API_KEY', ''), ) TIKA_SERVER_URL = PersistentConfig( - "TIKA_SERVER_URL", - "rag.tika_server_url", - os.getenv("TIKA_SERVER_URL", "http://tika:9998"), # Default for sidecar deployment + 'TIKA_SERVER_URL', + 'rag.tika_server_url', + os.getenv('TIKA_SERVER_URL', 'http://tika:9998'), # Default for sidecar deployment ) DOCLING_SERVER_URL = PersistentConfig( - "DOCLING_SERVER_URL", - "rag.docling_server_url", - os.getenv("DOCLING_SERVER_URL", "http://docling:5001"), + 'DOCLING_SERVER_URL', + 'rag.docling_server_url', + os.getenv('DOCLING_SERVER_URL', 'http://docling:5001'), ) DOCLING_API_KEY = PersistentConfig( - "DOCLING_API_KEY", - "rag.docling_api_key", - os.getenv("DOCLING_API_KEY", ""), + 'DOCLING_API_KEY', + 'rag.docling_api_key', + os.getenv('DOCLING_API_KEY', ''), ) -docling_params = os.getenv("DOCLING_PARAMS", "") +docling_params = os.getenv('DOCLING_PARAMS', '') try: docling_params = json.loads(docling_params) except json.JSONDecodeError: docling_params = {} DOCLING_PARAMS = PersistentConfig( - "DOCLING_PARAMS", - "rag.docling_params", + 'DOCLING_PARAMS', + 'rag.docling_params', docling_params, ) DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig( - "DOCUMENT_INTELLIGENCE_ENDPOINT", - "rag.document_intelligence_endpoint", - os.getenv("DOCUMENT_INTELLIGENCE_ENDPOINT", ""), + 'DOCUMENT_INTELLIGENCE_ENDPOINT', + 'rag.document_intelligence_endpoint', + os.getenv('DOCUMENT_INTELLIGENCE_ENDPOINT', ''), ) DOCUMENT_INTELLIGENCE_KEY = PersistentConfig( - "DOCUMENT_INTELLIGENCE_KEY", - "rag.document_intelligence_key", - os.getenv("DOCUMENT_INTELLIGENCE_KEY", ""), + 'DOCUMENT_INTELLIGENCE_KEY', + 'rag.document_intelligence_key', + os.getenv('DOCUMENT_INTELLIGENCE_KEY', ''), ) DOCUMENT_INTELLIGENCE_MODEL = PersistentConfig( - "DOCUMENT_INTELLIGENCE_MODEL", - "rag.document_intelligence_model", - os.getenv("DOCUMENT_INTELLIGENCE_MODEL", "prebuilt-layout"), + 'DOCUMENT_INTELLIGENCE_MODEL', + 'rag.document_intelligence_model', + os.getenv('DOCUMENT_INTELLIGENCE_MODEL', 'prebuilt-layout'), ) MISTRAL_OCR_API_BASE_URL = PersistentConfig( - "MISTRAL_OCR_API_BASE_URL", - "rag.MISTRAL_OCR_API_BASE_URL", - os.getenv("MISTRAL_OCR_API_BASE_URL", "https://api.mistral.ai/v1"), + 'MISTRAL_OCR_API_BASE_URL', + 'rag.MISTRAL_OCR_API_BASE_URL', + os.getenv('MISTRAL_OCR_API_BASE_URL', 'https://api.mistral.ai/v1'), ) MISTRAL_OCR_API_KEY = PersistentConfig( - "MISTRAL_OCR_API_KEY", - "rag.mistral_ocr_api_key", - os.getenv("MISTRAL_OCR_API_KEY", ""), + 'MISTRAL_OCR_API_KEY', + 'rag.mistral_ocr_api_key', + os.getenv('MISTRAL_OCR_API_KEY', ''), ) BYPASS_EMBEDDING_AND_RETRIEVAL = PersistentConfig( - "BYPASS_EMBEDDING_AND_RETRIEVAL", - "rag.bypass_embedding_and_retrieval", - os.environ.get("BYPASS_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true", + 'BYPASS_EMBEDDING_AND_RETRIEVAL', + 'rag.bypass_embedding_and_retrieval', + os.environ.get('BYPASS_EMBEDDING_AND_RETRIEVAL', 'False').lower() == 'true', ) -RAG_TOP_K = PersistentConfig( - "RAG_TOP_K", "rag.top_k", int(os.environ.get("RAG_TOP_K", "3")) -) +RAG_TOP_K = PersistentConfig('RAG_TOP_K', 'rag.top_k', int(os.environ.get('RAG_TOP_K', '3'))) RAG_TOP_K_RERANKER = PersistentConfig( - "RAG_TOP_K_RERANKER", - "rag.top_k_reranker", - int(os.environ.get("RAG_TOP_K_RERANKER", "3")), + 'RAG_TOP_K_RERANKER', + 'rag.top_k_reranker', + int(os.environ.get('RAG_TOP_K_RERANKER', '3')), ) RAG_RELEVANCE_THRESHOLD = PersistentConfig( - "RAG_RELEVANCE_THRESHOLD", - "rag.relevance_threshold", - float(os.environ.get("RAG_RELEVANCE_THRESHOLD", "0.0")), + 'RAG_RELEVANCE_THRESHOLD', + 'rag.relevance_threshold', + float(os.environ.get('RAG_RELEVANCE_THRESHOLD', '0.0')), ) RAG_HYBRID_BM25_WEIGHT = PersistentConfig( - "RAG_HYBRID_BM25_WEIGHT", - "rag.hybrid_bm25_weight", - float(os.environ.get("RAG_HYBRID_BM25_WEIGHT", "0.5")), + 'RAG_HYBRID_BM25_WEIGHT', + 'rag.hybrid_bm25_weight', + float(os.environ.get('RAG_HYBRID_BM25_WEIGHT', '0.5')), ) ENABLE_RAG_HYBRID_SEARCH = PersistentConfig( - "ENABLE_RAG_HYBRID_SEARCH", - "rag.enable_hybrid_search", - os.environ.get("ENABLE_RAG_HYBRID_SEARCH", "").lower() == "true", + 'ENABLE_RAG_HYBRID_SEARCH', + 'rag.enable_hybrid_search', + os.environ.get('ENABLE_RAG_HYBRID_SEARCH', '').lower() == 'true', ) ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = PersistentConfig( - "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS", - "rag.enable_hybrid_search_enriched_texts", - os.environ.get("ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS", "False").lower() - == "true", + 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS', + 'rag.enable_hybrid_search_enriched_texts', + os.environ.get('ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS', 'False').lower() == 'true', ) RAG_FULL_CONTEXT = PersistentConfig( - "RAG_FULL_CONTEXT", - "rag.full_context", - os.getenv("RAG_FULL_CONTEXT", "False").lower() == "true", + 'RAG_FULL_CONTEXT', + 'rag.full_context', + os.getenv('RAG_FULL_CONTEXT', 'False').lower() == 'true', ) RAG_FILE_MAX_COUNT = PersistentConfig( - "RAG_FILE_MAX_COUNT", - "rag.file.max_count", - ( - int(os.environ.get("RAG_FILE_MAX_COUNT")) - if os.environ.get("RAG_FILE_MAX_COUNT") - else None - ), + 'RAG_FILE_MAX_COUNT', + 'rag.file.max_count', + (int(os.environ.get('RAG_FILE_MAX_COUNT')) if os.environ.get('RAG_FILE_MAX_COUNT') else None), ) RAG_FILE_MAX_SIZE = PersistentConfig( - "RAG_FILE_MAX_SIZE", - "rag.file.max_size", - ( - int(os.environ.get("RAG_FILE_MAX_SIZE")) - if os.environ.get("RAG_FILE_MAX_SIZE") - else None - ), + 'RAG_FILE_MAX_SIZE', + 'rag.file.max_size', + (int(os.environ.get('RAG_FILE_MAX_SIZE')) if os.environ.get('RAG_FILE_MAX_SIZE') else None), ) FILE_IMAGE_COMPRESSION_WIDTH = PersistentConfig( - "FILE_IMAGE_COMPRESSION_WIDTH", - "file.image_compression_width", - ( - int(os.environ.get("FILE_IMAGE_COMPRESSION_WIDTH")) - if os.environ.get("FILE_IMAGE_COMPRESSION_WIDTH") - else None - ), + 'FILE_IMAGE_COMPRESSION_WIDTH', + 'file.image_compression_width', + (int(os.environ.get('FILE_IMAGE_COMPRESSION_WIDTH')) if os.environ.get('FILE_IMAGE_COMPRESSION_WIDTH') else None), ) FILE_IMAGE_COMPRESSION_HEIGHT = PersistentConfig( - "FILE_IMAGE_COMPRESSION_HEIGHT", - "file.image_compression_height", - ( - int(os.environ.get("FILE_IMAGE_COMPRESSION_HEIGHT")) - if os.environ.get("FILE_IMAGE_COMPRESSION_HEIGHT") - else None - ), + 'FILE_IMAGE_COMPRESSION_HEIGHT', + 'file.image_compression_height', + (int(os.environ.get('FILE_IMAGE_COMPRESSION_HEIGHT')) if os.environ.get('FILE_IMAGE_COMPRESSION_HEIGHT') else None), ) RAG_ALLOWED_FILE_EXTENSIONS = PersistentConfig( - "RAG_ALLOWED_FILE_EXTENSIONS", - "rag.file.allowed_extensions", - [ - ext.strip() - for ext in os.environ.get("RAG_ALLOWED_FILE_EXTENSIONS", "").split(",") - if ext.strip() - ], + 'RAG_ALLOWED_FILE_EXTENSIONS', + 'rag.file.allowed_extensions', + [ext.strip() for ext in os.environ.get('RAG_ALLOWED_FILE_EXTENSIONS', '').split(',') if ext.strip()], ) RAG_EMBEDDING_ENGINE = PersistentConfig( - "RAG_EMBEDDING_ENGINE", - "rag.embedding_engine", - os.environ.get("RAG_EMBEDDING_ENGINE", ""), + 'RAG_EMBEDDING_ENGINE', + 'rag.embedding_engine', + os.environ.get('RAG_EMBEDDING_ENGINE', ''), ) PDF_EXTRACT_IMAGES = PersistentConfig( - "PDF_EXTRACT_IMAGES", - "rag.pdf_extract_images", - os.environ.get("PDF_EXTRACT_IMAGES", "False").lower() == "true", + 'PDF_EXTRACT_IMAGES', + 'rag.pdf_extract_images', + os.environ.get('PDF_EXTRACT_IMAGES', 'False').lower() == 'true', ) PDF_LOADER_MODE = PersistentConfig( - "PDF_LOADER_MODE", - "rag.pdf_loader_mode", - os.environ.get("PDF_LOADER_MODE", "page"), + 'PDF_LOADER_MODE', + 'rag.pdf_loader_mode', + os.environ.get('PDF_LOADER_MODE', 'page'), ) RAG_EMBEDDING_MODEL = PersistentConfig( - "RAG_EMBEDDING_MODEL", - "rag.embedding_model", - os.environ.get("RAG_EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2"), + 'RAG_EMBEDDING_MODEL', + 'rag.embedding_model', + os.environ.get('RAG_EMBEDDING_MODEL', 'sentence-transformers/all-MiniLM-L6-v2'), ) -log.info(f"Embedding model set: {RAG_EMBEDDING_MODEL.value}") +log.info(f'Embedding model set: {RAG_EMBEDDING_MODEL.value}') RAG_EMBEDDING_MODEL_AUTO_UPDATE = ( - not OFFLINE_MODE - and os.environ.get("RAG_EMBEDDING_MODEL_AUTO_UPDATE", "True").lower() == "true" + not OFFLINE_MODE and os.environ.get('RAG_EMBEDDING_MODEL_AUTO_UPDATE', 'True').lower() == 'true' ) RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE = ( - os.environ.get("RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE", "True").lower() == "true" + os.environ.get('RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true' ) RAG_EMBEDDING_BATCH_SIZE = PersistentConfig( - "RAG_EMBEDDING_BATCH_SIZE", - "rag.embedding_batch_size", - int( - os.environ.get("RAG_EMBEDDING_BATCH_SIZE") - or os.environ.get("RAG_EMBEDDING_OPENAI_BATCH_SIZE", "1") - ), + 'RAG_EMBEDDING_BATCH_SIZE', + 'rag.embedding_batch_size', + int(os.environ.get('RAG_EMBEDDING_BATCH_SIZE') or os.environ.get('RAG_EMBEDDING_OPENAI_BATCH_SIZE', '1')), ) ENABLE_ASYNC_EMBEDDING = PersistentConfig( - "ENABLE_ASYNC_EMBEDDING", - "rag.enable_async_embedding", - os.environ.get("ENABLE_ASYNC_EMBEDDING", "True").lower() == "true", + 'ENABLE_ASYNC_EMBEDDING', + 'rag.enable_async_embedding', + os.environ.get('ENABLE_ASYNC_EMBEDDING', 'True').lower() == 'true', ) RAG_EMBEDDING_CONCURRENT_REQUESTS = PersistentConfig( - "RAG_EMBEDDING_CONCURRENT_REQUESTS", - "rag.embedding_concurrent_requests", - int(os.getenv("RAG_EMBEDDING_CONCURRENT_REQUESTS", "0")), + 'RAG_EMBEDDING_CONCURRENT_REQUESTS', + 'rag.embedding_concurrent_requests', + int(os.getenv('RAG_EMBEDDING_CONCURRENT_REQUESTS', '0')), ) -RAG_EMBEDDING_QUERY_PREFIX = os.environ.get("RAG_EMBEDDING_QUERY_PREFIX", None) +RAG_EMBEDDING_QUERY_PREFIX = os.environ.get('RAG_EMBEDDING_QUERY_PREFIX', None) -RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get("RAG_EMBEDDING_CONTENT_PREFIX", None) +RAG_EMBEDDING_CONTENT_PREFIX = os.environ.get('RAG_EMBEDDING_CONTENT_PREFIX', None) -RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get( - "RAG_EMBEDDING_PREFIX_FIELD_NAME", None -) +RAG_EMBEDDING_PREFIX_FIELD_NAME = os.environ.get('RAG_EMBEDDING_PREFIX_FIELD_NAME', None) RAG_RERANKING_ENGINE = PersistentConfig( - "RAG_RERANKING_ENGINE", - "rag.reranking_engine", - os.environ.get("RAG_RERANKING_ENGINE", ""), + 'RAG_RERANKING_ENGINE', + 'rag.reranking_engine', + os.environ.get('RAG_RERANKING_ENGINE', ''), ) RAG_RERANKING_MODEL = PersistentConfig( - "RAG_RERANKING_MODEL", - "rag.reranking_model", - os.environ.get("RAG_RERANKING_MODEL", ""), + 'RAG_RERANKING_MODEL', + 'rag.reranking_model', + os.environ.get('RAG_RERANKING_MODEL', ''), ) -if RAG_RERANKING_MODEL.value != "": - log.info(f"Reranking model set: {RAG_RERANKING_MODEL.value}") +if RAG_RERANKING_MODEL.value != '': + log.info(f'Reranking model set: {RAG_RERANKING_MODEL.value}') RAG_RERANKING_MODEL_AUTO_UPDATE = ( - not OFFLINE_MODE - and os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "True").lower() == "true" + not OFFLINE_MODE and os.environ.get('RAG_RERANKING_MODEL_AUTO_UPDATE', 'True').lower() == 'true' ) RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = ( - os.environ.get("RAG_RERANKING_MODEL_TRUST_REMOTE_CODE", "True").lower() == "true" + os.environ.get('RAG_RERANKING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true' ) RAG_EXTERNAL_RERANKER_URL = PersistentConfig( - "RAG_EXTERNAL_RERANKER_URL", - "rag.external_reranker_url", - os.environ.get("RAG_EXTERNAL_RERANKER_URL", ""), + 'RAG_EXTERNAL_RERANKER_URL', + 'rag.external_reranker_url', + os.environ.get('RAG_EXTERNAL_RERANKER_URL', ''), ) RAG_EXTERNAL_RERANKER_API_KEY = PersistentConfig( - "RAG_EXTERNAL_RERANKER_API_KEY", - "rag.external_reranker_api_key", - os.environ.get("RAG_EXTERNAL_RERANKER_API_KEY", ""), + 'RAG_EXTERNAL_RERANKER_API_KEY', + 'rag.external_reranker_api_key', + os.environ.get('RAG_EXTERNAL_RERANKER_API_KEY', ''), ) RAG_EXTERNAL_RERANKER_TIMEOUT = PersistentConfig( - "RAG_EXTERNAL_RERANKER_TIMEOUT", - "rag.external_reranker_timeout", - os.environ.get("RAG_EXTERNAL_RERANKER_TIMEOUT", ""), + 'RAG_EXTERNAL_RERANKER_TIMEOUT', + 'rag.external_reranker_timeout', + os.environ.get('RAG_EXTERNAL_RERANKER_TIMEOUT', ''), ) RAG_TEXT_SPLITTER = PersistentConfig( - "RAG_TEXT_SPLITTER", - "rag.text_splitter", - os.environ.get("RAG_TEXT_SPLITTER", ""), + 'RAG_TEXT_SPLITTER', + 'rag.text_splitter', + os.environ.get('RAG_TEXT_SPLITTER', ''), ) ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = PersistentConfig( - "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER", - "rag.enable_markdown_header_text_splitter", - os.environ.get("ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER", "True").lower() == "true", + 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER', + 'rag.enable_markdown_header_text_splitter', + os.environ.get('ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER', 'True').lower() == 'true', ) -TIKTOKEN_CACHE_DIR = os.environ.get("TIKTOKEN_CACHE_DIR", f"{CACHE_DIR}/tiktoken") +TIKTOKEN_CACHE_DIR = os.environ.get('TIKTOKEN_CACHE_DIR', f'{CACHE_DIR}/tiktoken') TIKTOKEN_ENCODING_NAME = PersistentConfig( - "TIKTOKEN_ENCODING_NAME", - "rag.tiktoken_encoding_name", - os.environ.get("TIKTOKEN_ENCODING_NAME", "cl100k_base"), + 'TIKTOKEN_ENCODING_NAME', + 'rag.tiktoken_encoding_name', + os.environ.get('TIKTOKEN_ENCODING_NAME', 'cl100k_base'), ) -CHUNK_SIZE = PersistentConfig( - "CHUNK_SIZE", "rag.chunk_size", int(os.environ.get("CHUNK_SIZE", "1000")) -) +CHUNK_SIZE = PersistentConfig('CHUNK_SIZE', 'rag.chunk_size', int(os.environ.get('CHUNK_SIZE', '1000'))) CHUNK_MIN_SIZE_TARGET = PersistentConfig( - "CHUNK_MIN_SIZE_TARGET", - "rag.chunk_min_size_target", - int(os.environ.get("CHUNK_MIN_SIZE_TARGET", "0")), + 'CHUNK_MIN_SIZE_TARGET', + 'rag.chunk_min_size_target', + int(os.environ.get('CHUNK_MIN_SIZE_TARGET', '0')), ) CHUNK_OVERLAP = PersistentConfig( - "CHUNK_OVERLAP", - "rag.chunk_overlap", - int(os.environ.get("CHUNK_OVERLAP", "100")), + 'CHUNK_OVERLAP', + 'rag.chunk_overlap', + int(os.environ.get('CHUNK_OVERLAP', '100')), ) DEFAULT_RAG_TEMPLATE = """### Task: @@ -3144,82 +2893,78 @@ class BannerModel(BaseModel): """ RAG_TEMPLATE = PersistentConfig( - "RAG_TEMPLATE", - "rag.template", - os.environ.get("RAG_TEMPLATE", DEFAULT_RAG_TEMPLATE), + 'RAG_TEMPLATE', + 'rag.template', + os.environ.get('RAG_TEMPLATE', DEFAULT_RAG_TEMPLATE), ) RAG_OPENAI_API_BASE_URL = PersistentConfig( - "RAG_OPENAI_API_BASE_URL", - "rag.openai_api_base_url", - os.getenv("RAG_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), + 'RAG_OPENAI_API_BASE_URL', + 'rag.openai_api_base_url', + os.getenv('RAG_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), ) RAG_OPENAI_API_KEY = PersistentConfig( - "RAG_OPENAI_API_KEY", - "rag.openai_api_key", - os.getenv("RAG_OPENAI_API_KEY", OPENAI_API_KEY), + 'RAG_OPENAI_API_KEY', + 'rag.openai_api_key', + os.getenv('RAG_OPENAI_API_KEY', OPENAI_API_KEY), ) RAG_AZURE_OPENAI_BASE_URL = PersistentConfig( - "RAG_AZURE_OPENAI_BASE_URL", - "rag.azure_openai.base_url", - os.getenv("RAG_AZURE_OPENAI_BASE_URL", ""), + 'RAG_AZURE_OPENAI_BASE_URL', + 'rag.azure_openai.base_url', + os.getenv('RAG_AZURE_OPENAI_BASE_URL', ''), ) RAG_AZURE_OPENAI_API_KEY = PersistentConfig( - "RAG_AZURE_OPENAI_API_KEY", - "rag.azure_openai.api_key", - os.getenv("RAG_AZURE_OPENAI_API_KEY", ""), + 'RAG_AZURE_OPENAI_API_KEY', + 'rag.azure_openai.api_key', + os.getenv('RAG_AZURE_OPENAI_API_KEY', ''), ) RAG_AZURE_OPENAI_API_VERSION = PersistentConfig( - "RAG_AZURE_OPENAI_API_VERSION", - "rag.azure_openai.api_version", - os.getenv("RAG_AZURE_OPENAI_API_VERSION", ""), + 'RAG_AZURE_OPENAI_API_VERSION', + 'rag.azure_openai.api_version', + os.getenv('RAG_AZURE_OPENAI_API_VERSION', ''), ) RAG_OLLAMA_BASE_URL = PersistentConfig( - "RAG_OLLAMA_BASE_URL", - "rag.ollama.url", - os.getenv("RAG_OLLAMA_BASE_URL", OLLAMA_BASE_URL), + 'RAG_OLLAMA_BASE_URL', + 'rag.ollama.url', + os.getenv('RAG_OLLAMA_BASE_URL', OLLAMA_BASE_URL), ) RAG_OLLAMA_API_KEY = PersistentConfig( - "RAG_OLLAMA_API_KEY", - "rag.ollama.key", - os.getenv("RAG_OLLAMA_API_KEY", ""), + 'RAG_OLLAMA_API_KEY', + 'rag.ollama.key', + os.getenv('RAG_OLLAMA_API_KEY', ''), ) -ENABLE_RAG_LOCAL_WEB_FETCH = ( - os.getenv("ENABLE_RAG_LOCAL_WEB_FETCH", "False").lower() == "true" -) +ENABLE_RAG_LOCAL_WEB_FETCH = os.getenv('ENABLE_RAG_LOCAL_WEB_FETCH', 'False').lower() == 'true' DEFAULT_WEB_FETCH_FILTER_LIST = [ - "!169.254.169.254", - "!fd00:ec2::254", - "!metadata.google.internal", - "!metadata.azure.com", - "!100.100.100.200", + '!169.254.169.254', + '!fd00:ec2::254', + '!metadata.google.internal', + '!metadata.azure.com', + '!100.100.100.200', ] -web_fetch_filter_list = os.getenv("WEB_FETCH_FILTER_LIST", "") -if web_fetch_filter_list == "": +web_fetch_filter_list = os.getenv('WEB_FETCH_FILTER_LIST', '') +if web_fetch_filter_list == '': web_fetch_filter_list = [] else: - web_fetch_filter_list = [ - item.strip() for item in web_fetch_filter_list.split(",") if item.strip() - ] + web_fetch_filter_list = [item.strip() for item in web_fetch_filter_list.split(',') if item.strip()] WEB_FETCH_FILTER_LIST = list(set(DEFAULT_WEB_FETCH_FILTER_LIST + web_fetch_filter_list)) YOUTUBE_LOADER_LANGUAGE = PersistentConfig( - "YOUTUBE_LOADER_LANGUAGE", - "rag.youtube_loader_language", - os.getenv("YOUTUBE_LOADER_LANGUAGE", "en").split(","), + 'YOUTUBE_LOADER_LANGUAGE', + 'rag.youtube_loader_language', + os.getenv('YOUTUBE_LOADER_LANGUAGE', 'en').split(','), ) YOUTUBE_LOADER_PROXY_URL = PersistentConfig( - "YOUTUBE_LOADER_PROXY_URL", - "rag.youtube_loader_proxy_url", - os.getenv("YOUTUBE_LOADER_PROXY_URL", ""), + 'YOUTUBE_LOADER_PROXY_URL', + 'rag.youtube_loader_proxy_url', + os.getenv('YOUTUBE_LOADER_PROXY_URL', ''), ) #################################### @@ -3227,40 +2972,38 @@ class BannerModel(BaseModel): #################################### ENABLE_WEB_SEARCH = PersistentConfig( - "ENABLE_WEB_SEARCH", - "rag.web.search.enable", - os.getenv("ENABLE_WEB_SEARCH", "False").lower() == "true", + 'ENABLE_WEB_SEARCH', + 'rag.web.search.enable', + os.getenv('ENABLE_WEB_SEARCH', 'False').lower() == 'true', ) WEB_SEARCH_ENGINE = PersistentConfig( - "WEB_SEARCH_ENGINE", - "rag.web.search.engine", - os.getenv("WEB_SEARCH_ENGINE", ""), + 'WEB_SEARCH_ENGINE', + 'rag.web.search.engine', + os.getenv('WEB_SEARCH_ENGINE', ''), ) BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = PersistentConfig( - "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", - "rag.web.search.bypass_embedding_and_retrieval", - os.getenv("BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL", "False").lower() == "true", + 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL', + 'rag.web.search.bypass_embedding_and_retrieval', + os.getenv('BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL', 'False').lower() == 'true', ) BYPASS_WEB_SEARCH_WEB_LOADER = PersistentConfig( - "BYPASS_WEB_SEARCH_WEB_LOADER", - "rag.web.search.bypass_web_loader", - os.getenv("BYPASS_WEB_SEARCH_WEB_LOADER", "False").lower() == "true", + 'BYPASS_WEB_SEARCH_WEB_LOADER', + 'rag.web.search.bypass_web_loader', + os.getenv('BYPASS_WEB_SEARCH_WEB_LOADER', 'False').lower() == 'true', ) WEB_SEARCH_RESULT_COUNT = PersistentConfig( - "WEB_SEARCH_RESULT_COUNT", - "rag.web.search.result_count", - int(os.getenv("WEB_SEARCH_RESULT_COUNT", "3")), + 'WEB_SEARCH_RESULT_COUNT', + 'rag.web.search.result_count', + int(os.getenv('WEB_SEARCH_RESULT_COUNT', '3')), ) try: - web_search_domain_filter_list = json.loads( - os.getenv("WEB_SEARCH_DOMAIN_FILTER_LIST", "[]") - ) + web_search_domain_filter_list = json.loads(os.getenv('WEB_SEARCH_DOMAIN_FILTER_LIST', '[]')) except Exception as e: web_search_domain_filter_list = [ # "wikipedia.com", @@ -3272,347 +3015,351 @@ class BannerModel(BaseModel): # You can provide a list of your own websites to filter after performing a web search. # This ensures the highest level of safety and reliability of the information sources. WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig( - "WEB_SEARCH_DOMAIN_FILTER_LIST", - "rag.web.search.domain.filter_list", + 'WEB_SEARCH_DOMAIN_FILTER_LIST', + 'rag.web.search.domain.filter_list', web_search_domain_filter_list, ) WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig( - "WEB_SEARCH_CONCURRENT_REQUESTS", - "rag.web.search.concurrent_requests", - int(os.getenv("WEB_SEARCH_CONCURRENT_REQUESTS", "0")), + 'WEB_SEARCH_CONCURRENT_REQUESTS', + 'rag.web.search.concurrent_requests', + int(os.getenv('WEB_SEARCH_CONCURRENT_REQUESTS', '0')), +) + +WEB_FETCH_MAX_CONTENT_LENGTH = PersistentConfig( + 'WEB_FETCH_MAX_CONTENT_LENGTH', + 'rag.web.search.fetch_url_max_content_length', + (int(os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH')) if os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH') else None), ) WEB_LOADER_ENGINE = PersistentConfig( - "WEB_LOADER_ENGINE", - "rag.web.loader.engine", - os.environ.get("WEB_LOADER_ENGINE", ""), + 'WEB_LOADER_ENGINE', + 'rag.web.loader.engine', + os.environ.get('WEB_LOADER_ENGINE', ''), ) WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig( - "WEB_LOADER_CONCURRENT_REQUESTS", - "rag.web.loader.concurrent_requests", - int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")), + 'WEB_LOADER_CONCURRENT_REQUESTS', + 'rag.web.loader.concurrent_requests', + int(os.getenv('WEB_LOADER_CONCURRENT_REQUESTS', '10')), ) WEB_LOADER_TIMEOUT = PersistentConfig( - "WEB_LOADER_TIMEOUT", - "rag.web.loader.timeout", - os.getenv("WEB_LOADER_TIMEOUT", ""), + 'WEB_LOADER_TIMEOUT', + 'rag.web.loader.timeout', + os.getenv('WEB_LOADER_TIMEOUT', ''), ) ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig( - "ENABLE_WEB_LOADER_SSL_VERIFICATION", - "rag.web.loader.ssl_verification", - os.environ.get("ENABLE_WEB_LOADER_SSL_VERIFICATION", "True").lower() == "true", + 'ENABLE_WEB_LOADER_SSL_VERIFICATION', + 'rag.web.loader.ssl_verification', + os.environ.get('ENABLE_WEB_LOADER_SSL_VERIFICATION', 'True').lower() == 'true', ) WEB_SEARCH_TRUST_ENV = PersistentConfig( - "WEB_SEARCH_TRUST_ENV", - "rag.web.search.trust_env", - os.getenv("WEB_SEARCH_TRUST_ENV", "False").lower() == "true", + 'WEB_SEARCH_TRUST_ENV', + 'rag.web.search.trust_env', + os.getenv('WEB_SEARCH_TRUST_ENV', 'False').lower() == 'true', ) OLLAMA_CLOUD_WEB_SEARCH_API_KEY = PersistentConfig( - "OLLAMA_CLOUD_WEB_SEARCH_API_KEY", - "rag.web.search.ollama_cloud_api_key", - os.getenv("OLLAMA_CLOUD_API_KEY", ""), + 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY', + 'rag.web.search.ollama_cloud_api_key', + os.getenv('OLLAMA_CLOUD_API_KEY', ''), ) SEARXNG_QUERY_URL = PersistentConfig( - "SEARXNG_QUERY_URL", - "rag.web.search.searxng_query_url", - os.getenv("SEARXNG_QUERY_URL", ""), + 'SEARXNG_QUERY_URL', + 'rag.web.search.searxng_query_url', + os.getenv('SEARXNG_QUERY_URL', ''), ) SEARXNG_LANGUAGE = PersistentConfig( - "SEARXNG_LANGUAGE", - "rag.web.search.searxng_language", - os.getenv("SEARXNG_LANGUAGE", "all"), + 'SEARXNG_LANGUAGE', + 'rag.web.search.searxng_language', + os.getenv('SEARXNG_LANGUAGE', 'all'), ) YACY_QUERY_URL = PersistentConfig( - "YACY_QUERY_URL", - "rag.web.search.yacy_query_url", - os.getenv("YACY_QUERY_URL", ""), + 'YACY_QUERY_URL', + 'rag.web.search.yacy_query_url', + os.getenv('YACY_QUERY_URL', ''), ) YACY_USERNAME = PersistentConfig( - "YACY_USERNAME", - "rag.web.search.yacy_username", - os.getenv("YACY_USERNAME", ""), + 'YACY_USERNAME', + 'rag.web.search.yacy_username', + os.getenv('YACY_USERNAME', ''), ) YACY_PASSWORD = PersistentConfig( - "YACY_PASSWORD", - "rag.web.search.yacy_password", - os.getenv("YACY_PASSWORD", ""), + 'YACY_PASSWORD', + 'rag.web.search.yacy_password', + os.getenv('YACY_PASSWORD', ''), ) GOOGLE_PSE_API_KEY = PersistentConfig( - "GOOGLE_PSE_API_KEY", - "rag.web.search.google_pse_api_key", - os.getenv("GOOGLE_PSE_API_KEY", ""), + 'GOOGLE_PSE_API_KEY', + 'rag.web.search.google_pse_api_key', + os.getenv('GOOGLE_PSE_API_KEY', ''), ) GOOGLE_PSE_ENGINE_ID = PersistentConfig( - "GOOGLE_PSE_ENGINE_ID", - "rag.web.search.google_pse_engine_id", - os.getenv("GOOGLE_PSE_ENGINE_ID", ""), + 'GOOGLE_PSE_ENGINE_ID', + 'rag.web.search.google_pse_engine_id', + os.getenv('GOOGLE_PSE_ENGINE_ID', ''), ) BRAVE_SEARCH_API_KEY = PersistentConfig( - "BRAVE_SEARCH_API_KEY", - "rag.web.search.brave_search_api_key", - os.getenv("BRAVE_SEARCH_API_KEY", ""), + 'BRAVE_SEARCH_API_KEY', + 'rag.web.search.brave_search_api_key', + os.getenv('BRAVE_SEARCH_API_KEY', ''), ) KAGI_SEARCH_API_KEY = PersistentConfig( - "KAGI_SEARCH_API_KEY", - "rag.web.search.kagi_search_api_key", - os.getenv("KAGI_SEARCH_API_KEY", ""), + 'KAGI_SEARCH_API_KEY', + 'rag.web.search.kagi_search_api_key', + os.getenv('KAGI_SEARCH_API_KEY', ''), ) MOJEEK_SEARCH_API_KEY = PersistentConfig( - "MOJEEK_SEARCH_API_KEY", - "rag.web.search.mojeek_search_api_key", - os.getenv("MOJEEK_SEARCH_API_KEY", ""), + 'MOJEEK_SEARCH_API_KEY', + 'rag.web.search.mojeek_search_api_key', + os.getenv('MOJEEK_SEARCH_API_KEY', ''), ) BOCHA_SEARCH_API_KEY = PersistentConfig( - "BOCHA_SEARCH_API_KEY", - "rag.web.search.bocha_search_api_key", - os.getenv("BOCHA_SEARCH_API_KEY", ""), + 'BOCHA_SEARCH_API_KEY', + 'rag.web.search.bocha_search_api_key', + os.getenv('BOCHA_SEARCH_API_KEY', ''), ) SERPSTACK_API_KEY = PersistentConfig( - "SERPSTACK_API_KEY", - "rag.web.search.serpstack_api_key", - os.getenv("SERPSTACK_API_KEY", ""), + 'SERPSTACK_API_KEY', + 'rag.web.search.serpstack_api_key', + os.getenv('SERPSTACK_API_KEY', ''), ) SERPSTACK_HTTPS = PersistentConfig( - "SERPSTACK_HTTPS", - "rag.web.search.serpstack_https", - os.getenv("SERPSTACK_HTTPS", "True").lower() == "true", + 'SERPSTACK_HTTPS', + 'rag.web.search.serpstack_https', + os.getenv('SERPSTACK_HTTPS', 'True').lower() == 'true', ) SERPER_API_KEY = PersistentConfig( - "SERPER_API_KEY", - "rag.web.search.serper_api_key", - os.getenv("SERPER_API_KEY", ""), + 'SERPER_API_KEY', + 'rag.web.search.serper_api_key', + os.getenv('SERPER_API_KEY', ''), ) SERPLY_API_KEY = PersistentConfig( - "SERPLY_API_KEY", - "rag.web.search.serply_api_key", - os.getenv("SERPLY_API_KEY", ""), + 'SERPLY_API_KEY', + 'rag.web.search.serply_api_key', + os.getenv('SERPLY_API_KEY', ''), ) DDGS_BACKEND = PersistentConfig( - "DDGS_BACKEND", - "rag.web.search.ddgs_backend", - os.getenv("DDGS_BACKEND", "auto"), + 'DDGS_BACKEND', + 'rag.web.search.ddgs_backend', + os.getenv('DDGS_BACKEND', 'auto'), ) JINA_API_KEY = PersistentConfig( - "JINA_API_KEY", - "rag.web.search.jina_api_key", - os.getenv("JINA_API_KEY", ""), + 'JINA_API_KEY', + 'rag.web.search.jina_api_key', + os.getenv('JINA_API_KEY', ''), ) JINA_API_BASE_URL = PersistentConfig( - "JINA_API_BASE_URL", - "rag.web.search.jina_api_base_url", - os.getenv("JINA_API_BASE_URL", ""), + 'JINA_API_BASE_URL', + 'rag.web.search.jina_api_base_url', + os.getenv('JINA_API_BASE_URL', ''), ) SEARCHAPI_API_KEY = PersistentConfig( - "SEARCHAPI_API_KEY", - "rag.web.search.searchapi_api_key", - os.getenv("SEARCHAPI_API_KEY", ""), + 'SEARCHAPI_API_KEY', + 'rag.web.search.searchapi_api_key', + os.getenv('SEARCHAPI_API_KEY', ''), ) SEARCHAPI_ENGINE = PersistentConfig( - "SEARCHAPI_ENGINE", - "rag.web.search.searchapi_engine", - os.getenv("SEARCHAPI_ENGINE", ""), + 'SEARCHAPI_ENGINE', + 'rag.web.search.searchapi_engine', + os.getenv('SEARCHAPI_ENGINE', ''), ) SERPAPI_API_KEY = PersistentConfig( - "SERPAPI_API_KEY", - "rag.web.search.serpapi_api_key", - os.getenv("SERPAPI_API_KEY", ""), + 'SERPAPI_API_KEY', + 'rag.web.search.serpapi_api_key', + os.getenv('SERPAPI_API_KEY', ''), ) SERPAPI_ENGINE = PersistentConfig( - "SERPAPI_ENGINE", - "rag.web.search.serpapi_engine", - os.getenv("SERPAPI_ENGINE", ""), + 'SERPAPI_ENGINE', + 'rag.web.search.serpapi_engine', + os.getenv('SERPAPI_ENGINE', ''), ) BING_SEARCH_V7_ENDPOINT = PersistentConfig( - "BING_SEARCH_V7_ENDPOINT", - "rag.web.search.bing_search_v7_endpoint", - os.environ.get( - "BING_SEARCH_V7_ENDPOINT", "https://api.bing.microsoft.com/v7.0/search" - ), + 'BING_SEARCH_V7_ENDPOINT', + 'rag.web.search.bing_search_v7_endpoint', + os.environ.get('BING_SEARCH_V7_ENDPOINT', 'https://api.bing.microsoft.com/v7.0/search'), ) BING_SEARCH_V7_SUBSCRIPTION_KEY = PersistentConfig( - "BING_SEARCH_V7_SUBSCRIPTION_KEY", - "rag.web.search.bing_search_v7_subscription_key", - os.environ.get("BING_SEARCH_V7_SUBSCRIPTION_KEY", ""), + 'BING_SEARCH_V7_SUBSCRIPTION_KEY', + 'rag.web.search.bing_search_v7_subscription_key', + os.environ.get('BING_SEARCH_V7_SUBSCRIPTION_KEY', ''), ) AZURE_AI_SEARCH_API_KEY = PersistentConfig( - "AZURE_AI_SEARCH_API_KEY", - "rag.web.search.azure_ai_search_api_key", - os.environ.get("AZURE_AI_SEARCH_API_KEY", ""), + 'AZURE_AI_SEARCH_API_KEY', + 'rag.web.search.azure_ai_search_api_key', + os.environ.get('AZURE_AI_SEARCH_API_KEY', ''), ) AZURE_AI_SEARCH_ENDPOINT = PersistentConfig( - "AZURE_AI_SEARCH_ENDPOINT", - "rag.web.search.azure_ai_search_endpoint", - os.environ.get("AZURE_AI_SEARCH_ENDPOINT", ""), + 'AZURE_AI_SEARCH_ENDPOINT', + 'rag.web.search.azure_ai_search_endpoint', + os.environ.get('AZURE_AI_SEARCH_ENDPOINT', ''), ) AZURE_AI_SEARCH_INDEX_NAME = PersistentConfig( - "AZURE_AI_SEARCH_INDEX_NAME", - "rag.web.search.azure_ai_search_index_name", - os.environ.get("AZURE_AI_SEARCH_INDEX_NAME", ""), + 'AZURE_AI_SEARCH_INDEX_NAME', + 'rag.web.search.azure_ai_search_index_name', + os.environ.get('AZURE_AI_SEARCH_INDEX_NAME', ''), ) EXA_API_KEY = PersistentConfig( - "EXA_API_KEY", - "rag.web.search.exa_api_key", - os.getenv("EXA_API_KEY", ""), + 'EXA_API_KEY', + 'rag.web.search.exa_api_key', + os.getenv('EXA_API_KEY', ''), ) PERPLEXITY_API_KEY = PersistentConfig( - "PERPLEXITY_API_KEY", - "rag.web.search.perplexity_api_key", - os.getenv("PERPLEXITY_API_KEY", ""), + 'PERPLEXITY_API_KEY', + 'rag.web.search.perplexity_api_key', + os.getenv('PERPLEXITY_API_KEY', ''), ) PERPLEXITY_MODEL = PersistentConfig( - "PERPLEXITY_MODEL", - "rag.web.search.perplexity_model", - os.getenv("PERPLEXITY_MODEL", "sonar"), + 'PERPLEXITY_MODEL', + 'rag.web.search.perplexity_model', + os.getenv('PERPLEXITY_MODEL', 'sonar'), ) PERPLEXITY_SEARCH_CONTEXT_USAGE = PersistentConfig( - "PERPLEXITY_SEARCH_CONTEXT_USAGE", - "rag.web.search.perplexity_search_context_usage", - os.getenv("PERPLEXITY_SEARCH_CONTEXT_USAGE", "medium"), + 'PERPLEXITY_SEARCH_CONTEXT_USAGE', + 'rag.web.search.perplexity_search_context_usage', + os.getenv('PERPLEXITY_SEARCH_CONTEXT_USAGE', 'medium'), ) PERPLEXITY_SEARCH_API_URL = PersistentConfig( - "PERPLEXITY_SEARCH_API_URL", - "rag.web.search.perplexity_search_api_url", - os.getenv("PERPLEXITY_SEARCH_API_URL", "https://api.perplexity.ai/search"), + 'PERPLEXITY_SEARCH_API_URL', + 'rag.web.search.perplexity_search_api_url', + os.getenv('PERPLEXITY_SEARCH_API_URL', 'https://api.perplexity.ai/search'), ) SOUGOU_API_SID = PersistentConfig( - "SOUGOU_API_SID", - "rag.web.search.sougou_api_sid", - os.getenv("SOUGOU_API_SID", ""), + 'SOUGOU_API_SID', + 'rag.web.search.sougou_api_sid', + os.getenv('SOUGOU_API_SID', ''), ) SOUGOU_API_SK = PersistentConfig( - "SOUGOU_API_SK", - "rag.web.search.sougou_api_sk", - os.getenv("SOUGOU_API_SK", ""), + 'SOUGOU_API_SK', + 'rag.web.search.sougou_api_sk', + os.getenv('SOUGOU_API_SK', ''), ) TAVILY_API_KEY = PersistentConfig( - "TAVILY_API_KEY", - "rag.web.search.tavily_api_key", - os.getenv("TAVILY_API_KEY", ""), + 'TAVILY_API_KEY', + 'rag.web.search.tavily_api_key', + os.getenv('TAVILY_API_KEY', ''), ) TAVILY_EXTRACT_DEPTH = PersistentConfig( - "TAVILY_EXTRACT_DEPTH", - "rag.web.search.tavily_extract_depth", - os.getenv("TAVILY_EXTRACT_DEPTH", "basic"), + 'TAVILY_EXTRACT_DEPTH', + 'rag.web.search.tavily_extract_depth', + os.getenv('TAVILY_EXTRACT_DEPTH', 'basic'), ) PLAYWRIGHT_WS_URL = PersistentConfig( - "PLAYWRIGHT_WS_URL", - "rag.web.loader.playwright_ws_url", - os.environ.get("PLAYWRIGHT_WS_URL", ""), + 'PLAYWRIGHT_WS_URL', + 'rag.web.loader.playwright_ws_url', + os.environ.get('PLAYWRIGHT_WS_URL', ''), ) PLAYWRIGHT_TIMEOUT = PersistentConfig( - "PLAYWRIGHT_TIMEOUT", - "rag.web.loader.playwright_timeout", - int(os.environ.get("PLAYWRIGHT_TIMEOUT", "10000")), + 'PLAYWRIGHT_TIMEOUT', + 'rag.web.loader.playwright_timeout', + int(os.environ.get('PLAYWRIGHT_TIMEOUT', '10000')), ) FIRECRAWL_API_KEY = PersistentConfig( - "FIRECRAWL_API_KEY", - "rag.web.loader.firecrawl_api_key", - os.environ.get("FIRECRAWL_API_KEY", ""), + 'FIRECRAWL_API_KEY', + 'rag.web.loader.firecrawl_api_key', + os.environ.get('FIRECRAWL_API_KEY', ''), ) FIRECRAWL_API_BASE_URL = PersistentConfig( - "FIRECRAWL_API_BASE_URL", - "rag.web.loader.firecrawl_api_url", - os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"), + 'FIRECRAWL_API_BASE_URL', + 'rag.web.loader.firecrawl_api_url', + os.environ.get('FIRECRAWL_API_BASE_URL', 'https://api.firecrawl.dev'), ) FIRECRAWL_TIMEOUT = PersistentConfig( - "FIRECRAWL_TIMEOUT", - "rag.web.loader.firecrawl_timeout", - os.environ.get("FIRECRAWL_TIMEOUT", ""), + 'FIRECRAWL_TIMEOUT', + 'rag.web.loader.firecrawl_timeout', + os.environ.get('FIRECRAWL_TIMEOUT', ''), ) EXTERNAL_WEB_SEARCH_URL = PersistentConfig( - "EXTERNAL_WEB_SEARCH_URL", - "rag.web.search.external_web_search_url", - os.environ.get("EXTERNAL_WEB_SEARCH_URL", ""), + 'EXTERNAL_WEB_SEARCH_URL', + 'rag.web.search.external_web_search_url', + os.environ.get('EXTERNAL_WEB_SEARCH_URL', ''), ) EXTERNAL_WEB_SEARCH_API_KEY = PersistentConfig( - "EXTERNAL_WEB_SEARCH_API_KEY", - "rag.web.search.external_web_search_api_key", - os.environ.get("EXTERNAL_WEB_SEARCH_API_KEY", ""), + 'EXTERNAL_WEB_SEARCH_API_KEY', + 'rag.web.search.external_web_search_api_key', + os.environ.get('EXTERNAL_WEB_SEARCH_API_KEY', ''), ) EXTERNAL_WEB_LOADER_URL = PersistentConfig( - "EXTERNAL_WEB_LOADER_URL", - "rag.web.loader.external_web_loader_url", - os.environ.get("EXTERNAL_WEB_LOADER_URL", ""), + 'EXTERNAL_WEB_LOADER_URL', + 'rag.web.loader.external_web_loader_url', + os.environ.get('EXTERNAL_WEB_LOADER_URL', ''), ) EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig( - "EXTERNAL_WEB_LOADER_API_KEY", - "rag.web.loader.external_web_loader_api_key", - os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""), + 'EXTERNAL_WEB_LOADER_API_KEY', + 'rag.web.loader.external_web_loader_api_key', + os.environ.get('EXTERNAL_WEB_LOADER_API_KEY', ''), ) YANDEX_WEB_SEARCH_URL = PersistentConfig( - "YANDEX_WEB_SEARCH_URL", - "rag.web.search.yandex_web_search_url", - os.environ.get("YANDEX_WEB_SEARCH_URL", ""), + 'YANDEX_WEB_SEARCH_URL', + 'rag.web.search.yandex_web_search_url', + os.environ.get('YANDEX_WEB_SEARCH_URL', ''), ) YANDEX_WEB_SEARCH_API_KEY = PersistentConfig( - "YANDEX_WEB_SEARCH_API_KEY", - "rag.web.search.yandex_web_search_api_key", - os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""), + 'YANDEX_WEB_SEARCH_API_KEY', + 'rag.web.search.yandex_web_search_api_key', + os.environ.get('YANDEX_WEB_SEARCH_API_KEY', ''), ) YANDEX_WEB_SEARCH_CONFIG = PersistentConfig( - "YANDEX_WEB_SEARCH_CONFIG", - "rag.web.search.yandex_web_search_config", - os.environ.get("YANDEX_WEB_SEARCH_CONFIG", ""), + 'YANDEX_WEB_SEARCH_CONFIG', + 'rag.web.search.yandex_web_search_config', + os.environ.get('YANDEX_WEB_SEARCH_CONFIG', ''), ) YOUCOM_API_KEY = PersistentConfig( - "YOUCOM_API_KEY", - "rag.web.search.youcom_api_key", - os.environ.get("YOUCOM_API_KEY", ""), + 'YOUCOM_API_KEY', + 'rag.web.search.youcom_api_key', + os.environ.get('YOUCOM_API_KEY', ''), ) #################################### @@ -3620,80 +3367,72 @@ class BannerModel(BaseModel): #################################### ENABLE_IMAGE_GENERATION = PersistentConfig( - "ENABLE_IMAGE_GENERATION", - "image_generation.enable", - os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true", + 'ENABLE_IMAGE_GENERATION', + 'image_generation.enable', + os.environ.get('ENABLE_IMAGE_GENERATION', '').lower() == 'true', ) IMAGE_GENERATION_ENGINE = PersistentConfig( - "IMAGE_GENERATION_ENGINE", - "image_generation.engine", - os.getenv("IMAGE_GENERATION_ENGINE", "openai"), + 'IMAGE_GENERATION_ENGINE', + 'image_generation.engine', + os.getenv('IMAGE_GENERATION_ENGINE', 'openai'), ) IMAGE_GENERATION_MODEL = PersistentConfig( - "IMAGE_GENERATION_MODEL", - "image_generation.model", - os.getenv("IMAGE_GENERATION_MODEL", ""), + 'IMAGE_GENERATION_MODEL', + 'image_generation.model', + os.getenv('IMAGE_GENERATION_MODEL', ''), ) # Regex pattern for models that support IMAGE_SIZE = "auto". -IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv( - "IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN", "^gpt-image" -) +IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN = os.getenv('IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN', '^gpt-image') # Regex pattern for models that return URLs instead of base64 data. -IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv( - "IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN", "^gpt-image" -) +IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN = os.getenv('IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN', '^gpt-image') -IMAGE_SIZE = PersistentConfig( - "IMAGE_SIZE", "image_generation.size", os.getenv("IMAGE_SIZE", "512x512") -) +IMAGE_SIZE = PersistentConfig('IMAGE_SIZE', 'image_generation.size', os.getenv('IMAGE_SIZE', '512x512')) -IMAGE_STEPS = PersistentConfig( - "IMAGE_STEPS", "image_generation.steps", int(os.getenv("IMAGE_STEPS", 50)) -) +IMAGE_STEPS = PersistentConfig('IMAGE_STEPS', 'image_generation.steps', int(os.getenv('IMAGE_STEPS', 50))) ENABLE_IMAGE_PROMPT_GENERATION = PersistentConfig( - "ENABLE_IMAGE_PROMPT_GENERATION", - "image_generation.prompt.enable", - os.environ.get("ENABLE_IMAGE_PROMPT_GENERATION", "true").lower() == "true", + 'ENABLE_IMAGE_PROMPT_GENERATION', + 'image_generation.prompt.enable', + os.environ.get('ENABLE_IMAGE_PROMPT_GENERATION', 'true').lower() == 'true', ) AUTOMATIC1111_BASE_URL = PersistentConfig( - "AUTOMATIC1111_BASE_URL", - "image_generation.automatic1111.base_url", - os.getenv("AUTOMATIC1111_BASE_URL", ""), + 'AUTOMATIC1111_BASE_URL', + 'image_generation.automatic1111.base_url', + os.getenv('AUTOMATIC1111_BASE_URL', ''), ) AUTOMATIC1111_API_AUTH = PersistentConfig( - "AUTOMATIC1111_API_AUTH", - "image_generation.automatic1111.api_auth", - os.getenv("AUTOMATIC1111_API_AUTH", ""), + 'AUTOMATIC1111_API_AUTH', + 'image_generation.automatic1111.api_auth', + os.getenv('AUTOMATIC1111_API_AUTH', ''), ) -automatic1111_params = os.getenv("AUTOMATIC1111_PARAMS", "") +automatic1111_params = os.getenv('AUTOMATIC1111_PARAMS', '') try: automatic1111_params = json.loads(automatic1111_params) except json.JSONDecodeError: automatic1111_params = {} AUTOMATIC1111_PARAMS = PersistentConfig( - "AUTOMATIC1111_PARAMS", - "image_generation.automatic1111.api_params", + 'AUTOMATIC1111_PARAMS', + 'image_generation.automatic1111.api_params', automatic1111_params, ) COMFYUI_BASE_URL = PersistentConfig( - "COMFYUI_BASE_URL", - "image_generation.comfyui.base_url", - os.getenv("COMFYUI_BASE_URL", ""), + 'COMFYUI_BASE_URL', + 'image_generation.comfyui.base_url', + os.getenv('COMFYUI_BASE_URL', ''), ) COMFYUI_API_KEY = PersistentConfig( - "COMFYUI_API_KEY", - "image_generation.comfyui.api_key", - os.getenv("COMFYUI_API_KEY", ""), + 'COMFYUI_API_KEY', + 'image_generation.comfyui.api_key', + os.getenv('COMFYUI_API_KEY', ''), ) COMFYUI_DEFAULT_WORKFLOW = """ @@ -3807,143 +3546,141 @@ class BannerModel(BaseModel): """ COMFYUI_WORKFLOW = PersistentConfig( - "COMFYUI_WORKFLOW", - "image_generation.comfyui.workflow", - os.getenv("COMFYUI_WORKFLOW", COMFYUI_DEFAULT_WORKFLOW), + 'COMFYUI_WORKFLOW', + 'image_generation.comfyui.workflow', + os.getenv('COMFYUI_WORKFLOW', COMFYUI_DEFAULT_WORKFLOW), ) -comfyui_workflow_nodes = os.getenv("COMFYUI_WORKFLOW_NODES", "") +comfyui_workflow_nodes = os.getenv('COMFYUI_WORKFLOW_NODES', '') try: comfyui_workflow_nodes = json.loads(comfyui_workflow_nodes) except json.JSONDecodeError: comfyui_workflow_nodes = [] COMFYUI_WORKFLOW_NODES = PersistentConfig( - "COMFYUI_WORKFLOW_NODES", - "image_generation.comfyui.nodes", + 'COMFYUI_WORKFLOW_NODES', + 'image_generation.comfyui.nodes', comfyui_workflow_nodes, ) IMAGES_OPENAI_API_BASE_URL = PersistentConfig( - "IMAGES_OPENAI_API_BASE_URL", - "image_generation.openai.api_base_url", - os.getenv("IMAGES_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), + 'IMAGES_OPENAI_API_BASE_URL', + 'image_generation.openai.api_base_url', + os.getenv('IMAGES_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), ) IMAGES_OPENAI_API_VERSION = PersistentConfig( - "IMAGES_OPENAI_API_VERSION", - "image_generation.openai.api_version", - os.getenv("IMAGES_OPENAI_API_VERSION", ""), + 'IMAGES_OPENAI_API_VERSION', + 'image_generation.openai.api_version', + os.getenv('IMAGES_OPENAI_API_VERSION', ''), ) IMAGES_OPENAI_API_KEY = PersistentConfig( - "IMAGES_OPENAI_API_KEY", - "image_generation.openai.api_key", - os.getenv("IMAGES_OPENAI_API_KEY", OPENAI_API_KEY), + 'IMAGES_OPENAI_API_KEY', + 'image_generation.openai.api_key', + os.getenv('IMAGES_OPENAI_API_KEY', OPENAI_API_KEY), ) -images_openai_params = os.getenv("IMAGES_OPENAI_PARAMS", "") +images_openai_params = os.getenv('IMAGES_OPENAI_PARAMS', '') try: images_openai_params = json.loads(images_openai_params) except json.JSONDecodeError: images_openai_params = {} IMAGES_OPENAI_API_PARAMS = PersistentConfig( - "IMAGES_OPENAI_API_PARAMS", "image_generation.openai.params", images_openai_params + 'IMAGES_OPENAI_API_PARAMS', 'image_generation.openai.params', images_openai_params ) IMAGES_GEMINI_API_BASE_URL = PersistentConfig( - "IMAGES_GEMINI_API_BASE_URL", - "image_generation.gemini.api_base_url", - os.getenv("IMAGES_GEMINI_API_BASE_URL", GEMINI_API_BASE_URL), + 'IMAGES_GEMINI_API_BASE_URL', + 'image_generation.gemini.api_base_url', + os.getenv('IMAGES_GEMINI_API_BASE_URL', GEMINI_API_BASE_URL), ) IMAGES_GEMINI_API_KEY = PersistentConfig( - "IMAGES_GEMINI_API_KEY", - "image_generation.gemini.api_key", - os.getenv("IMAGES_GEMINI_API_KEY", GEMINI_API_KEY), + 'IMAGES_GEMINI_API_KEY', + 'image_generation.gemini.api_key', + os.getenv('IMAGES_GEMINI_API_KEY', GEMINI_API_KEY), ) IMAGES_GEMINI_ENDPOINT_METHOD = PersistentConfig( - "IMAGES_GEMINI_ENDPOINT_METHOD", - "image_generation.gemini.endpoint_method", - os.getenv("IMAGES_GEMINI_ENDPOINT_METHOD", ""), + 'IMAGES_GEMINI_ENDPOINT_METHOD', + 'image_generation.gemini.endpoint_method', + os.getenv('IMAGES_GEMINI_ENDPOINT_METHOD', ''), ) ENABLE_IMAGE_EDIT = PersistentConfig( - "ENABLE_IMAGE_EDIT", - "images.edit.enable", - os.environ.get("ENABLE_IMAGE_EDIT", "").lower() == "true", + 'ENABLE_IMAGE_EDIT', + 'images.edit.enable', + os.environ.get('ENABLE_IMAGE_EDIT', '').lower() == 'true', ) IMAGE_EDIT_ENGINE = PersistentConfig( - "IMAGE_EDIT_ENGINE", - "images.edit.engine", - os.getenv("IMAGE_EDIT_ENGINE", "openai"), + 'IMAGE_EDIT_ENGINE', + 'images.edit.engine', + os.getenv('IMAGE_EDIT_ENGINE', 'openai'), ) IMAGE_EDIT_MODEL = PersistentConfig( - "IMAGE_EDIT_MODEL", - "images.edit.model", - os.getenv("IMAGE_EDIT_MODEL", ""), + 'IMAGE_EDIT_MODEL', + 'images.edit.model', + os.getenv('IMAGE_EDIT_MODEL', ''), ) -IMAGE_EDIT_SIZE = PersistentConfig( - "IMAGE_EDIT_SIZE", "images.edit.size", os.getenv("IMAGE_EDIT_SIZE", "") -) +IMAGE_EDIT_SIZE = PersistentConfig('IMAGE_EDIT_SIZE', 'images.edit.size', os.getenv('IMAGE_EDIT_SIZE', '')) IMAGES_EDIT_OPENAI_API_BASE_URL = PersistentConfig( - "IMAGES_EDIT_OPENAI_API_BASE_URL", - "images.edit.openai.api_base_url", - os.getenv("IMAGES_EDIT_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), + 'IMAGES_EDIT_OPENAI_API_BASE_URL', + 'images.edit.openai.api_base_url', + os.getenv('IMAGES_EDIT_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), ) IMAGES_EDIT_OPENAI_API_VERSION = PersistentConfig( - "IMAGES_EDIT_OPENAI_API_VERSION", - "images.edit.openai.api_version", - os.getenv("IMAGES_EDIT_OPENAI_API_VERSION", ""), + 'IMAGES_EDIT_OPENAI_API_VERSION', + 'images.edit.openai.api_version', + os.getenv('IMAGES_EDIT_OPENAI_API_VERSION', ''), ) IMAGES_EDIT_OPENAI_API_KEY = PersistentConfig( - "IMAGES_EDIT_OPENAI_API_KEY", - "images.edit.openai.api_key", - os.getenv("IMAGES_EDIT_OPENAI_API_KEY", OPENAI_API_KEY), + 'IMAGES_EDIT_OPENAI_API_KEY', + 'images.edit.openai.api_key', + os.getenv('IMAGES_EDIT_OPENAI_API_KEY', OPENAI_API_KEY), ) IMAGES_EDIT_GEMINI_API_BASE_URL = PersistentConfig( - "IMAGES_EDIT_GEMINI_API_BASE_URL", - "images.edit.gemini.api_base_url", - os.getenv("IMAGES_EDIT_GEMINI_API_BASE_URL", GEMINI_API_BASE_URL), + 'IMAGES_EDIT_GEMINI_API_BASE_URL', + 'images.edit.gemini.api_base_url', + os.getenv('IMAGES_EDIT_GEMINI_API_BASE_URL', GEMINI_API_BASE_URL), ) IMAGES_EDIT_GEMINI_API_KEY = PersistentConfig( - "IMAGES_EDIT_GEMINI_API_KEY", - "images.edit.gemini.api_key", - os.getenv("IMAGES_EDIT_GEMINI_API_KEY", GEMINI_API_KEY), + 'IMAGES_EDIT_GEMINI_API_KEY', + 'images.edit.gemini.api_key', + os.getenv('IMAGES_EDIT_GEMINI_API_KEY', GEMINI_API_KEY), ) IMAGES_EDIT_COMFYUI_BASE_URL = PersistentConfig( - "IMAGES_EDIT_COMFYUI_BASE_URL", - "images.edit.comfyui.base_url", - os.getenv("IMAGES_EDIT_COMFYUI_BASE_URL", ""), + 'IMAGES_EDIT_COMFYUI_BASE_URL', + 'images.edit.comfyui.base_url', + os.getenv('IMAGES_EDIT_COMFYUI_BASE_URL', ''), ) IMAGES_EDIT_COMFYUI_API_KEY = PersistentConfig( - "IMAGES_EDIT_COMFYUI_API_KEY", - "images.edit.comfyui.api_key", - os.getenv("IMAGES_EDIT_COMFYUI_API_KEY", ""), + 'IMAGES_EDIT_COMFYUI_API_KEY', + 'images.edit.comfyui.api_key', + os.getenv('IMAGES_EDIT_COMFYUI_API_KEY', ''), ) IMAGES_EDIT_COMFYUI_WORKFLOW = PersistentConfig( - "IMAGES_EDIT_COMFYUI_WORKFLOW", - "images.edit.comfyui.workflow", - os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW", ""), + 'IMAGES_EDIT_COMFYUI_WORKFLOW', + 'images.edit.comfyui.workflow', + os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW', ''), ) -images_edit_comfyui_workflow_nodes = os.getenv("IMAGES_EDIT_COMFYUI_WORKFLOW_NODES", "") +images_edit_comfyui_workflow_nodes = os.getenv('IMAGES_EDIT_COMFYUI_WORKFLOW_NODES', '') try: images_edit_comfyui_workflow_nodes = json.loads(images_edit_comfyui_workflow_nodes) except json.JSONDecodeError: images_edit_comfyui_workflow_nodes = [] IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = PersistentConfig( - "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES", - "images.edit.comfyui.nodes", + 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES', + 'images.edit.comfyui.nodes', images_edit_comfyui_workflow_nodes, ) @@ -3953,191 +3690,182 @@ class BannerModel(BaseModel): # Transcription WHISPER_MODEL = PersistentConfig( - "WHISPER_MODEL", - "audio.stt.whisper_model", - os.getenv("WHISPER_MODEL", "base"), + 'WHISPER_MODEL', + 'audio.stt.whisper_model', + os.getenv('WHISPER_MODEL', 'base'), ) -WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8") -WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models") -WHISPER_MODEL_AUTO_UPDATE = ( - not OFFLINE_MODE - and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true" -) +WHISPER_COMPUTE_TYPE = os.getenv('WHISPER_COMPUTE_TYPE', 'int8') +WHISPER_MODEL_DIR = os.getenv('WHISPER_MODEL_DIR', f'{CACHE_DIR}/whisper/models') +WHISPER_MODEL_AUTO_UPDATE = not OFFLINE_MODE and os.environ.get('WHISPER_MODEL_AUTO_UPDATE', '').lower() == 'true' -WHISPER_VAD_FILTER = os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true" +WHISPER_VAD_FILTER = os.getenv('WHISPER_VAD_FILTER', 'False').lower() == 'true' -WHISPER_MULTILINGUAL = os.getenv("WHISPER_MULTILINGUAL", "False").lower() == "true" +WHISPER_MULTILINGUAL = os.getenv('WHISPER_MULTILINGUAL', 'False').lower() == 'true' -WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None +WHISPER_LANGUAGE = os.getenv('WHISPER_LANGUAGE', '').lower() or None # Add Deepgram configuration DEEPGRAM_API_KEY = PersistentConfig( - "DEEPGRAM_API_KEY", - "audio.stt.deepgram.api_key", - os.getenv("DEEPGRAM_API_KEY", ""), + 'DEEPGRAM_API_KEY', + 'audio.stt.deepgram.api_key', + os.getenv('DEEPGRAM_API_KEY', ''), ) # ElevenLabs configuration -ELEVENLABS_API_BASE_URL = os.getenv( - "ELEVENLABS_API_BASE_URL", "https://api.elevenlabs.io" -) +ELEVENLABS_API_BASE_URL = os.getenv('ELEVENLABS_API_BASE_URL', 'https://api.elevenlabs.io') AUDIO_STT_OPENAI_API_BASE_URL = PersistentConfig( - "AUDIO_STT_OPENAI_API_BASE_URL", - "audio.stt.openai.api_base_url", - os.getenv("AUDIO_STT_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), + 'AUDIO_STT_OPENAI_API_BASE_URL', + 'audio.stt.openai.api_base_url', + os.getenv('AUDIO_STT_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), ) AUDIO_STT_OPENAI_API_KEY = PersistentConfig( - "AUDIO_STT_OPENAI_API_KEY", - "audio.stt.openai.api_key", - os.getenv("AUDIO_STT_OPENAI_API_KEY", OPENAI_API_KEY), + 'AUDIO_STT_OPENAI_API_KEY', + 'audio.stt.openai.api_key', + os.getenv('AUDIO_STT_OPENAI_API_KEY', OPENAI_API_KEY), ) AUDIO_STT_ENGINE = PersistentConfig( - "AUDIO_STT_ENGINE", - "audio.stt.engine", - os.getenv("AUDIO_STT_ENGINE", ""), + 'AUDIO_STT_ENGINE', + 'audio.stt.engine', + os.getenv('AUDIO_STT_ENGINE', ''), ) AUDIO_STT_MODEL = PersistentConfig( - "AUDIO_STT_MODEL", - "audio.stt.model", - os.getenv("AUDIO_STT_MODEL", ""), + 'AUDIO_STT_MODEL', + 'audio.stt.model', + os.getenv('AUDIO_STT_MODEL', ''), ) AUDIO_STT_SUPPORTED_CONTENT_TYPES = PersistentConfig( - "AUDIO_STT_SUPPORTED_CONTENT_TYPES", - "audio.stt.supported_content_types", + 'AUDIO_STT_SUPPORTED_CONTENT_TYPES', + 'audio.stt.supported_content_types', [ content_type.strip() - for content_type in os.environ.get( - "AUDIO_STT_SUPPORTED_CONTENT_TYPES", "" - ).split(",") + for content_type in os.environ.get('AUDIO_STT_SUPPORTED_CONTENT_TYPES', '').split(',') if content_type.strip() ], ) AUDIO_STT_AZURE_API_KEY = PersistentConfig( - "AUDIO_STT_AZURE_API_KEY", - "audio.stt.azure.api_key", - os.getenv("AUDIO_STT_AZURE_API_KEY", ""), + 'AUDIO_STT_AZURE_API_KEY', + 'audio.stt.azure.api_key', + os.getenv('AUDIO_STT_AZURE_API_KEY', ''), ) AUDIO_STT_AZURE_REGION = PersistentConfig( - "AUDIO_STT_AZURE_REGION", - "audio.stt.azure.region", - os.getenv("AUDIO_STT_AZURE_REGION", ""), + 'AUDIO_STT_AZURE_REGION', + 'audio.stt.azure.region', + os.getenv('AUDIO_STT_AZURE_REGION', ''), ) AUDIO_STT_AZURE_LOCALES = PersistentConfig( - "AUDIO_STT_AZURE_LOCALES", - "audio.stt.azure.locales", - os.getenv("AUDIO_STT_AZURE_LOCALES", ""), + 'AUDIO_STT_AZURE_LOCALES', + 'audio.stt.azure.locales', + os.getenv('AUDIO_STT_AZURE_LOCALES', ''), ) AUDIO_STT_AZURE_BASE_URL = PersistentConfig( - "AUDIO_STT_AZURE_BASE_URL", - "audio.stt.azure.base_url", - os.getenv("AUDIO_STT_AZURE_BASE_URL", ""), + 'AUDIO_STT_AZURE_BASE_URL', + 'audio.stt.azure.base_url', + os.getenv('AUDIO_STT_AZURE_BASE_URL', ''), ) AUDIO_STT_AZURE_MAX_SPEAKERS = PersistentConfig( - "AUDIO_STT_AZURE_MAX_SPEAKERS", - "audio.stt.azure.max_speakers", - os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", ""), + 'AUDIO_STT_AZURE_MAX_SPEAKERS', + 'audio.stt.azure.max_speakers', + os.getenv('AUDIO_STT_AZURE_MAX_SPEAKERS', ''), ) AUDIO_STT_MISTRAL_API_KEY = PersistentConfig( - "AUDIO_STT_MISTRAL_API_KEY", - "audio.stt.mistral.api_key", - os.getenv("AUDIO_STT_MISTRAL_API_KEY", ""), + 'AUDIO_STT_MISTRAL_API_KEY', + 'audio.stt.mistral.api_key', + os.getenv('AUDIO_STT_MISTRAL_API_KEY', ''), ) AUDIO_STT_MISTRAL_API_BASE_URL = PersistentConfig( - "AUDIO_STT_MISTRAL_API_BASE_URL", - "audio.stt.mistral.api_base_url", - os.getenv("AUDIO_STT_MISTRAL_API_BASE_URL", "https://api.mistral.ai/v1"), + 'AUDIO_STT_MISTRAL_API_BASE_URL', + 'audio.stt.mistral.api_base_url', + os.getenv('AUDIO_STT_MISTRAL_API_BASE_URL', 'https://api.mistral.ai/v1'), ) AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = PersistentConfig( - "AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS", - "audio.stt.mistral.use_chat_completions", - os.getenv("AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS", "false").lower() == "true", + 'AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS', + 'audio.stt.mistral.use_chat_completions', + os.getenv('AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS', 'false').lower() == 'true', ) AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig( - "AUDIO_TTS_OPENAI_API_BASE_URL", - "audio.tts.openai.api_base_url", - os.getenv("AUDIO_TTS_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), + 'AUDIO_TTS_OPENAI_API_BASE_URL', + 'audio.tts.openai.api_base_url', + os.getenv('AUDIO_TTS_OPENAI_API_BASE_URL', OPENAI_API_BASE_URL), ) AUDIO_TTS_OPENAI_API_KEY = PersistentConfig( - "AUDIO_TTS_OPENAI_API_KEY", - "audio.tts.openai.api_key", - os.getenv("AUDIO_TTS_OPENAI_API_KEY", OPENAI_API_KEY), + 'AUDIO_TTS_OPENAI_API_KEY', + 'audio.tts.openai.api_key', + os.getenv('AUDIO_TTS_OPENAI_API_KEY', OPENAI_API_KEY), ) -audio_tts_openai_params = os.getenv("AUDIO_TTS_OPENAI_PARAMS", "") +audio_tts_openai_params = os.getenv('AUDIO_TTS_OPENAI_PARAMS', '') try: audio_tts_openai_params = json.loads(audio_tts_openai_params) except json.JSONDecodeError: audio_tts_openai_params = {} AUDIO_TTS_OPENAI_PARAMS = PersistentConfig( - "AUDIO_TTS_OPENAI_PARAMS", - "audio.tts.openai.params", + 'AUDIO_TTS_OPENAI_PARAMS', + 'audio.tts.openai.params', audio_tts_openai_params, ) AUDIO_TTS_API_KEY = PersistentConfig( - "AUDIO_TTS_API_KEY", - "audio.tts.api_key", - os.getenv("AUDIO_TTS_API_KEY", ""), + 'AUDIO_TTS_API_KEY', + 'audio.tts.api_key', + os.getenv('AUDIO_TTS_API_KEY', ''), ) AUDIO_TTS_ENGINE = PersistentConfig( - "AUDIO_TTS_ENGINE", - "audio.tts.engine", - os.getenv("AUDIO_TTS_ENGINE", ""), + 'AUDIO_TTS_ENGINE', + 'audio.tts.engine', + os.getenv('AUDIO_TTS_ENGINE', ''), ) AUDIO_TTS_MODEL = PersistentConfig( - "AUDIO_TTS_MODEL", - "audio.tts.model", - os.getenv("AUDIO_TTS_MODEL", "tts-1"), # OpenAI default model + 'AUDIO_TTS_MODEL', + 'audio.tts.model', + os.getenv('AUDIO_TTS_MODEL', 'tts-1'), # OpenAI default model ) AUDIO_TTS_VOICE = PersistentConfig( - "AUDIO_TTS_VOICE", - "audio.tts.voice", - os.getenv("AUDIO_TTS_VOICE", "alloy"), # OpenAI default voice + 'AUDIO_TTS_VOICE', + 'audio.tts.voice', + os.getenv('AUDIO_TTS_VOICE', 'alloy'), # OpenAI default voice ) AUDIO_TTS_SPLIT_ON = PersistentConfig( - "AUDIO_TTS_SPLIT_ON", - "audio.tts.split_on", - os.getenv("AUDIO_TTS_SPLIT_ON", "punctuation"), + 'AUDIO_TTS_SPLIT_ON', + 'audio.tts.split_on', + os.getenv('AUDIO_TTS_SPLIT_ON', 'punctuation'), ) AUDIO_TTS_AZURE_SPEECH_REGION = PersistentConfig( - "AUDIO_TTS_AZURE_SPEECH_REGION", - "audio.tts.azure.speech_region", - os.getenv("AUDIO_TTS_AZURE_SPEECH_REGION", ""), + 'AUDIO_TTS_AZURE_SPEECH_REGION', + 'audio.tts.azure.speech_region', + os.getenv('AUDIO_TTS_AZURE_SPEECH_REGION', ''), ) AUDIO_TTS_AZURE_SPEECH_BASE_URL = PersistentConfig( - "AUDIO_TTS_AZURE_SPEECH_BASE_URL", - "audio.tts.azure.speech_base_url", - os.getenv("AUDIO_TTS_AZURE_SPEECH_BASE_URL", ""), + 'AUDIO_TTS_AZURE_SPEECH_BASE_URL', + 'audio.tts.azure.speech_base_url', + os.getenv('AUDIO_TTS_AZURE_SPEECH_BASE_URL', ''), ) AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT = PersistentConfig( - "AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT", - "audio.tts.azure.speech_output_format", - os.getenv( - "AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT", "audio-24khz-160kbitrate-mono-mp3" - ), + 'AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT', + 'audio.tts.azure.speech_output_format', + os.getenv('AUDIO_TTS_AZURE_SPEECH_OUTPUT_FORMAT', 'audio-24khz-160kbitrate-mono-mp3'), ) #################################### @@ -4145,100 +3873,94 @@ class BannerModel(BaseModel): #################################### ENABLE_LDAP = PersistentConfig( - "ENABLE_LDAP", - "ldap.enable", - os.environ.get("ENABLE_LDAP", "false").lower() == "true", + 'ENABLE_LDAP', + 'ldap.enable', + os.environ.get('ENABLE_LDAP', 'false').lower() == 'true', ) LDAP_SERVER_LABEL = PersistentConfig( - "LDAP_SERVER_LABEL", - "ldap.server.label", - os.environ.get("LDAP_SERVER_LABEL", "LDAP Server"), + 'LDAP_SERVER_LABEL', + 'ldap.server.label', + os.environ.get('LDAP_SERVER_LABEL', 'LDAP Server'), ) LDAP_SERVER_HOST = PersistentConfig( - "LDAP_SERVER_HOST", - "ldap.server.host", - os.environ.get("LDAP_SERVER_HOST", "localhost"), + 'LDAP_SERVER_HOST', + 'ldap.server.host', + os.environ.get('LDAP_SERVER_HOST', 'localhost'), ) LDAP_SERVER_PORT = PersistentConfig( - "LDAP_SERVER_PORT", - "ldap.server.port", - int(os.environ.get("LDAP_SERVER_PORT", "389")), + 'LDAP_SERVER_PORT', + 'ldap.server.port', + int(os.environ.get('LDAP_SERVER_PORT', '389')), ) LDAP_ATTRIBUTE_FOR_MAIL = PersistentConfig( - "LDAP_ATTRIBUTE_FOR_MAIL", - "ldap.server.attribute_for_mail", - os.environ.get("LDAP_ATTRIBUTE_FOR_MAIL", "mail"), + 'LDAP_ATTRIBUTE_FOR_MAIL', + 'ldap.server.attribute_for_mail', + os.environ.get('LDAP_ATTRIBUTE_FOR_MAIL', 'mail'), ) LDAP_ATTRIBUTE_FOR_USERNAME = PersistentConfig( - "LDAP_ATTRIBUTE_FOR_USERNAME", - "ldap.server.attribute_for_username", - os.environ.get("LDAP_ATTRIBUTE_FOR_USERNAME", "uid"), + 'LDAP_ATTRIBUTE_FOR_USERNAME', + 'ldap.server.attribute_for_username', + os.environ.get('LDAP_ATTRIBUTE_FOR_USERNAME', 'uid'), ) -LDAP_APP_DN = PersistentConfig( - "LDAP_APP_DN", "ldap.server.app_dn", os.environ.get("LDAP_APP_DN", "") -) +LDAP_APP_DN = PersistentConfig('LDAP_APP_DN', 'ldap.server.app_dn', os.environ.get('LDAP_APP_DN', '')) LDAP_APP_PASSWORD = PersistentConfig( - "LDAP_APP_PASSWORD", - "ldap.server.app_password", - os.environ.get("LDAP_APP_PASSWORD", ""), + 'LDAP_APP_PASSWORD', + 'ldap.server.app_password', + os.environ.get('LDAP_APP_PASSWORD', ''), ) -LDAP_SEARCH_BASE = PersistentConfig( - "LDAP_SEARCH_BASE", "ldap.server.users_dn", os.environ.get("LDAP_SEARCH_BASE", "") -) +LDAP_SEARCH_BASE = PersistentConfig('LDAP_SEARCH_BASE', 'ldap.server.users_dn', os.environ.get('LDAP_SEARCH_BASE', '')) LDAP_SEARCH_FILTERS = PersistentConfig( - "LDAP_SEARCH_FILTER", - "ldap.server.search_filter", - os.environ.get("LDAP_SEARCH_FILTER", os.environ.get("LDAP_SEARCH_FILTERS", "")), + 'LDAP_SEARCH_FILTER', + 'ldap.server.search_filter', + os.environ.get('LDAP_SEARCH_FILTER', os.environ.get('LDAP_SEARCH_FILTERS', '')), ) LDAP_USE_TLS = PersistentConfig( - "LDAP_USE_TLS", - "ldap.server.use_tls", - os.environ.get("LDAP_USE_TLS", "True").lower() == "true", + 'LDAP_USE_TLS', + 'ldap.server.use_tls', + os.environ.get('LDAP_USE_TLS', 'True').lower() == 'true', ) LDAP_CA_CERT_FILE = PersistentConfig( - "LDAP_CA_CERT_FILE", - "ldap.server.ca_cert_file", - os.environ.get("LDAP_CA_CERT_FILE", ""), + 'LDAP_CA_CERT_FILE', + 'ldap.server.ca_cert_file', + os.environ.get('LDAP_CA_CERT_FILE', ''), ) LDAP_VALIDATE_CERT = PersistentConfig( - "LDAP_VALIDATE_CERT", - "ldap.server.validate_cert", - os.environ.get("LDAP_VALIDATE_CERT", "True").lower() == "true", + 'LDAP_VALIDATE_CERT', + 'ldap.server.validate_cert', + os.environ.get('LDAP_VALIDATE_CERT', 'True').lower() == 'true', ) -LDAP_CIPHERS = PersistentConfig( - "LDAP_CIPHERS", "ldap.server.ciphers", os.environ.get("LDAP_CIPHERS", "ALL") -) +LDAP_CIPHERS = PersistentConfig('LDAP_CIPHERS', 'ldap.server.ciphers', os.environ.get('LDAP_CIPHERS', 'ALL')) # For LDAP Group Management ENABLE_LDAP_GROUP_MANAGEMENT = PersistentConfig( - "ENABLE_LDAP_GROUP_MANAGEMENT", - "ldap.group.enable_management", - os.environ.get("ENABLE_LDAP_GROUP_MANAGEMENT", "False").lower() == "true", + 'ENABLE_LDAP_GROUP_MANAGEMENT', + 'ldap.group.enable_management', + os.environ.get('ENABLE_LDAP_GROUP_MANAGEMENT', 'False').lower() == 'true', ) ENABLE_LDAP_GROUP_CREATION = PersistentConfig( - "ENABLE_LDAP_GROUP_CREATION", - "ldap.group.enable_creation", - os.environ.get("ENABLE_LDAP_GROUP_CREATION", "False").lower() == "true", + 'ENABLE_LDAP_GROUP_CREATION', + 'ldap.group.enable_creation', + os.environ.get('ENABLE_LDAP_GROUP_CREATION', 'False').lower() == 'true', ) LDAP_ATTRIBUTE_FOR_GROUPS = PersistentConfig( - "LDAP_ATTRIBUTE_FOR_GROUPS", - "ldap.server.attribute_for_groups", - os.environ.get("LDAP_ATTRIBUTE_FOR_GROUPS", "memberOf"), + 'LDAP_ATTRIBUTE_FOR_GROUPS', + 'ldap.server.attribute_for_groups', + os.environ.get('LDAP_ATTRIBUTE_FOR_GROUPS', 'memberOf'), ) #################################### @@ -4247,169 +3969,169 @@ class BannerModel(BaseModel): CREDIT_NO_CHARGE_EMPTY_RESPONSE = PersistentConfig( - "CREDIT_NO_CHARGE_EMPTY_RESPONSE", - "credit.no_charge_empty_response", - os.environ.get("CREDIT_NO_CHARGE_EMPTY_RESPONSE", "True").lower() == "true", + 'CREDIT_NO_CHARGE_EMPTY_RESPONSE', + 'credit.no_charge_empty_response', + os.environ.get('CREDIT_NO_CHARGE_EMPTY_RESPONSE', 'True').lower() == 'true', ) CREDIT_NO_CREDIT_MSG = PersistentConfig( - "CREDIT_NO_CREDIT_MSG", - "credit.no_credit_msg", - os.environ.get("CREDIT_NO_CREDIT_MSG", "ไฝ™้ขไธ่ถณ๏ผŒ่ฏทๅ‰ๅพ€ ่ฎพ็ฝฎ-็งฏๅˆ† ๅ……ๅ€ผ"), + 'CREDIT_NO_CREDIT_MSG', + 'credit.no_credit_msg', + os.environ.get('CREDIT_NO_CREDIT_MSG', 'ไฝ™้ขไธ่ถณ๏ผŒ่ฏทๅ‰ๅพ€ ่ฎพ็ฝฎ-็งฏๅˆ† ๅ……ๅ€ผ'), ) CREDIT_EXCHANGE_RATIO = PersistentConfig( - "CREDIT_EXCHANGE_RATIO", - "credit.exchange.ratio", - os.environ.get("CREDIT_EXCHANGE_RATIO", "1"), + 'CREDIT_EXCHANGE_RATIO', + 'credit.exchange.ratio', + os.environ.get('CREDIT_EXCHANGE_RATIO', '1'), ) CREDIT_DEFAULT_CREDIT = PersistentConfig( - "CREDIT_DEFAULT_CREDIT", - "credit.default_credit", - os.environ.get("CREDIT_DEFAULT_CREDIT", "0"), + 'CREDIT_DEFAULT_CREDIT', + 'credit.default_credit', + os.environ.get('CREDIT_DEFAULT_CREDIT', '0'), ) USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE = PersistentConfig( - "USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE", - "credit.calculate.model_prefix_to_remove", - os.environ.get("USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE", ""), + 'USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE', + 'credit.calculate.model_prefix_to_remove', + os.environ.get('USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE', ''), ) USAGE_DEFAULT_ENCODING_MODEL = PersistentConfig( - "USAGE_DEFAULT_ENCODING_MODEL", - "credit.calculate.encoding.default_model", - os.environ.get("USAGE_DEFAULT_ENCODING_MODEL", "gpt-4o"), + 'USAGE_DEFAULT_ENCODING_MODEL', + 'credit.calculate.encoding.default_model', + os.environ.get('USAGE_DEFAULT_ENCODING_MODEL', 'gpt-4o'), ) USAGE_CALCULATE_DEFAULT_REQUEST_PRICE = PersistentConfig( - "USAGE_CALCULATE_DEFAULT_REQUEST_PRICE", - "credit.calculate.default_request_price", - os.environ.get("USAGE_CALCULATE_DEFAULT_REQUEST_PRICE", "0"), + 'USAGE_CALCULATE_DEFAULT_REQUEST_PRICE', + 'credit.calculate.default_request_price', + os.environ.get('USAGE_CALCULATE_DEFAULT_REQUEST_PRICE', '0'), ) USAGE_CALCULATE_DEFAULT_TOKEN_PRICE = PersistentConfig( - "USAGE_CALCULATE_DEFAULT_TOKEN_PRICE", - "credit.calculate.default_token_price", - os.environ.get("USAGE_CALCULATE_DEFAULT_TOKEN_PRICE", "0"), + 'USAGE_CALCULATE_DEFAULT_TOKEN_PRICE', + 'credit.calculate.default_token_price', + os.environ.get('USAGE_CALCULATE_DEFAULT_TOKEN_PRICE', '0'), ) USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE = PersistentConfig( - "USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE", - "credit.calculate.default_embedding_price", - os.environ.get("USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE", "0"), + 'USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE', + 'credit.calculate.default_embedding_price', + os.environ.get('USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE', '0'), ) USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE = PersistentConfig( - "USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE", - "credit.calculate.feature.image_gen_price", - os.environ.get("USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE", "0"), + 'USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE', + 'credit.calculate.feature.image_gen_price', + os.environ.get('USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE', '0'), ) USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE = PersistentConfig( - "USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE", - "credit.calculate.feature.code_execute_price", - os.environ.get("USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE", "0"), + 'USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE', + 'credit.calculate.feature.code_execute_price', + os.environ.get('USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE', '0'), ) USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE = PersistentConfig( - "USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE", - "credit.calculate.feature.web_search_price", - os.environ.get("USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE", "0"), + 'USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE', + 'credit.calculate.feature.web_search_price', + os.environ.get('USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE', '0'), ) USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE = PersistentConfig( - "USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE", - "credit.calculate.feature.tool_server_price", - os.environ.get("USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE", "0"), + 'USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE', + 'credit.calculate.feature.tool_server_price', + os.environ.get('USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE', '0'), ) USAGE_CALCULATE_MINIMUM_COST = PersistentConfig( - "USAGE_CALCULATE_MINIMUM_COST", - "credit.calculate.minimum_cost", - os.environ.get("USAGE_CALCULATE_MINIMUM_COST", "0"), + 'USAGE_CALCULATE_MINIMUM_COST', + 'credit.calculate.minimum_cost', + os.environ.get('USAGE_CALCULATE_MINIMUM_COST', '0'), ) USAGE_CUSTOM_PRICE_CONFIG = PersistentConfig( - "USAGE_CUSTOM_PRICE_CONFIG", - "credit.calculate.custom_price_config", - os.environ.get("USAGE_CUSTOM_PRICE_CONFIG", "[]"), + 'USAGE_CUSTOM_PRICE_CONFIG', + 'credit.calculate.custom_price_config', + os.environ.get('USAGE_CUSTOM_PRICE_CONFIG', '[]'), ) EZFP_PAY_PRIORITY = PersistentConfig( - "EZFP_PAY_PRIORITY", - "credit.ezfp.pay_priority", - os.environ.get("EZFP_PAY_PRIORITY", "qrcode"), + 'EZFP_PAY_PRIORITY', + 'credit.ezfp.pay_priority', + os.environ.get('EZFP_PAY_PRIORITY', 'qrcode'), ) EZFP_ENDPOINT = PersistentConfig( - "EZFP_ENDPOINT", - "credit.ezfp.endpoint", - os.environ.get("EZFP_ENDPOINT", ""), + 'EZFP_ENDPOINT', + 'credit.ezfp.endpoint', + os.environ.get('EZFP_ENDPOINT', ''), ) EZFP_PID = PersistentConfig( - "EZFP_PID", - "credit.ezfp.pid", - os.environ.get("EZFP_PID", ""), + 'EZFP_PID', + 'credit.ezfp.pid', + os.environ.get('EZFP_PID', ''), ) EZFP_KEY = PersistentConfig( - "EZFP_KEY", - "credit.ezfp.key", - os.environ.get("EZFP_KEY", ""), + 'EZFP_KEY', + 'credit.ezfp.key', + os.environ.get('EZFP_KEY', ''), ) EZFP_CALLBACK_HOST = PersistentConfig( - "EZFP_CALLBACK_HOST", - "credit.ezfp.callback_host", - os.environ.get("EZFP_CALLBACK_HOST", ""), + 'EZFP_CALLBACK_HOST', + 'credit.ezfp.callback_host', + os.environ.get('EZFP_CALLBACK_HOST', ''), ) EZFP_AMOUNT_CONTROL = PersistentConfig( - "EZFP_AMOUNT_CONTROL", - "credit.ezfp.amount_control", - os.environ.get("EZFP_AMOUNT_CONTROL", ""), + 'EZFP_AMOUNT_CONTROL', + 'credit.ezfp.amount_control', + os.environ.get('EZFP_AMOUNT_CONTROL', ''), ) ALIPAY_SERVER_URL = PersistentConfig( - "ALIPAY_SERVER_URL", - "credit.alipay.server_url", - os.environ.get("ALIPAY_SERVER_URL", "https://openapi.alipay.com/gateway.do"), + 'ALIPAY_SERVER_URL', + 'credit.alipay.server_url', + os.environ.get('ALIPAY_SERVER_URL', 'https://openapi.alipay.com/gateway.do'), ) ALIPAY_APP_ID = PersistentConfig( - "ALIPAY_APP_ID", - "credit.alipay.app_id", - os.environ.get("ALIPAY_APP_ID", ""), + 'ALIPAY_APP_ID', + 'credit.alipay.app_id', + os.environ.get('ALIPAY_APP_ID', ''), ) ALIPAY_APP_PRIVATE_KEY = PersistentConfig( - "ALIPAY_APP_PRIVATE_KEY", - "credit.alipay.app_private_key", - os.environ.get("ALIPAY_APP_PRIVATE_KEY", ""), + 'ALIPAY_APP_PRIVATE_KEY', + 'credit.alipay.app_private_key', + os.environ.get('ALIPAY_APP_PRIVATE_KEY', ''), ) ALIPAY_ALIPAY_PUBLIC_KEY = PersistentConfig( - "ALIPAY_ALIPAY_PUBLIC_KEY", - "credit.alipay.alipay_public_key", - os.environ.get("ALIPAY_ALIPAY_PUBLIC_KEY", ""), + 'ALIPAY_ALIPAY_PUBLIC_KEY', + 'credit.alipay.alipay_public_key', + os.environ.get('ALIPAY_ALIPAY_PUBLIC_KEY', ''), ) ALIPAY_CALLBACK_HOST = PersistentConfig( - "ALIPAY_CALLBACK_HOST", - "credit.alipay.callback_host", - os.environ.get("ALIPAY_CALLBACK_HOST", ""), + 'ALIPAY_CALLBACK_HOST', + 'credit.alipay.callback_host', + os.environ.get('ALIPAY_CALLBACK_HOST', ''), ) ALIPAY_AMOUNT_CONTROL = PersistentConfig( - "ALIPAY_AMOUNT_CONTROL", - "credit.alipay.amount_control", - os.environ.get("ALIPAY_AMOUNT_CONTROL", ""), + 'ALIPAY_AMOUNT_CONTROL', + 'credit.alipay.amount_control', + os.environ.get('ALIPAY_AMOUNT_CONTROL', ''), ) ALIPAY_PRODUCT_CODE = PersistentConfig( - "ALIPAY_PRODUCT_CODE", - "credit.alipay.product_code", - os.environ.get("ALIPAY_PRODUCT_CODE", ""), + 'ALIPAY_PRODUCT_CODE', + 'credit.alipay.product_code', + os.environ.get('ALIPAY_PRODUCT_CODE', ''), ) diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 4d39d16cdb..c0c79fdf50 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -2,125 +2,107 @@ class MESSAGES(str, Enum): - DEFAULT = lambda msg="": f"{msg if msg else ''}" - MODEL_ADDED = lambda model="": f"The model '{model}' has been added successfully." - MODEL_DELETED = ( - lambda model="": f"The model '{model}' has been deleted successfully." - ) + DEFAULT = lambda msg='': f'{msg if msg else ""}' + MODEL_ADDED = lambda model='': f"The model '{model}' has been added successfully." + MODEL_DELETED = lambda model='': f"The model '{model}' has been deleted successfully." class WEBHOOK_MESSAGES(str, Enum): - DEFAULT = lambda msg="": f"{msg if msg else ''}" - USER_SIGNUP = lambda username="": ( - f"New user signed up: {username}" if username else "New user signed up" - ) + DEFAULT = lambda msg='': f'{msg if msg else ""}' + USER_SIGNUP = lambda username='': f'New user signed up: {username}' if username else 'New user signed up' class ERROR_MESSAGES(str, Enum): def __str__(self) -> str: return super().__str__() - DEFAULT = ( - lambda err="": f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}' - ) - ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now." - CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance." - DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot." - EMAIL_MISMATCH = "Uh-oh! This email does not match the email your provider is registered with. Please check your email and try again." - EMAIL_TAKEN = "Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew." - USERNAME_TAKEN = ( - "Uh-oh! This username is already registered. Please choose another username." + DEFAULT = lambda err='': f'{"Something went wrong :/" if err == "" else "[ERROR: " + str(err) + "]"}' + ENV_VAR_NOT_FOUND = 'Required environment variable not found. Terminating now.' + CREATE_USER_ERROR = 'Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance.' + DELETE_USER_ERROR = 'Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot.' + EMAIL_MISMATCH = 'Uh-oh! This email does not match the email your provider is registered with. Please check your email and try again.' + EMAIL_TAKEN = 'Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew.' + USERNAME_TAKEN = 'Uh-oh! This username is already registered. Please choose another username.' + PASSWORD_TOO_LONG = ( + 'Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long.' ) - PASSWORD_TOO_LONG = "Uh-oh! The password you entered is too long. Please make sure your password is less than 72 bytes long." - COMMAND_TAKEN = "Uh-oh! This command is already registered. Please choose another command string." - FILE_EXISTS = "Uh-oh! This file is already registered. Please choose another file." + COMMAND_TAKEN = 'Uh-oh! This command is already registered. Please choose another command string.' + FILE_EXISTS = 'Uh-oh! This file is already registered. Please choose another file.' - ID_TAKEN = "Uh-oh! This id is already registered. Please choose another id string." - MODEL_ID_TAKEN = "Uh-oh! This model id is already registered. Please choose another model id string." - NAME_TAG_TAKEN = "Uh-oh! This name tag is already registered. Please choose another name tag string." - MODEL_ID_TOO_LONG = "The model id is too long. Please make sure your model id is less than 256 characters long." + ID_TAKEN = 'Uh-oh! This id is already registered. Please choose another id string.' + MODEL_ID_TAKEN = 'Uh-oh! This model id is already registered. Please choose another model id string.' + NAME_TAG_TAKEN = 'Uh-oh! This name tag is already registered. Please choose another name tag string.' + MODEL_ID_TOO_LONG = 'The model id is too long. Please make sure your model id is less than 256 characters long.' - INVALID_TOKEN = ( - "Your session has expired or the token is invalid. Please sign in again." - ) - INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again." + INVALID_TOKEN = 'Your session has expired or the token is invalid. Please sign in again.' + INVALID_CRED = 'The email or password provided is incorrect. Please check for typos and try logging in again.' INVALID_EMAIL_FORMAT = "The email format you entered is invalid. Please double-check and make sure you're using a valid email address (e.g., yourname@example.com)." - INCORRECT_PASSWORD = ( - "The password provided is incorrect. Please check for typos and try again." + INCORRECT_PASSWORD = 'The password provided is incorrect. Please check for typos and try again.' + INVALID_TRUSTED_HEADER = ( + 'Your provider has not provided a trusted header. Please contact your administrator for assistance.' ) - INVALID_TRUSTED_HEADER = "Your provider has not provided a trusted header. Please contact your administrator for assistance." EXISTING_USERS = "You can't turn off authentication because there are existing users. If you want to disable WEBUI_AUTH, make sure your web interface doesn't have any existing users and is a fresh installation." - UNAUTHORIZED = "401 Unauthorized" - ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance." - ACTION_PROHIBITED = ( - "The requested action has been restricted as a security measure." + UNAUTHORIZED = '401 Unauthorized' + ACCESS_PROHIBITED = ( + 'You do not have permission to access this resource. Please contact your administrator for assistance.' ) + ACTION_PROHIBITED = 'The requested action has been restricted as a security measure.' - FILE_NOT_SENT = "FILE_NOT_SENT" + FILE_NOT_SENT = 'FILE_NOT_SENT' FILE_NOT_SUPPORTED = "Oops! It seems like the file format you're trying to upload is not supported. Please upload a file with a supported format and try again." NOT_FOUND = "We could not find what you're looking for :/" USER_NOT_FOUND = "We could not find what you're looking for :/" API_KEY_NOT_FOUND = "Oops! It looks like there's a hiccup. The API key is missing. Please make sure to provide a valid API key to access this feature." - API_KEY_NOT_ALLOWED = "Use of API key is not enabled in the environment." + API_KEY_NOT_ALLOWED = 'Use of API key is not enabled in the environment.' - MALICIOUS = "Unusual activities detected, please try again in a few minutes." + MALICIOUS = 'Unusual activities detected, please try again in a few minutes.' - PANDOC_NOT_INSTALLED = "Pandoc is not installed on the server. Please contact your administrator for assistance." - INCORRECT_FORMAT = ( - lambda err="": f"Invalid format. Please use the correct format{err}" - ) - RATE_LIMIT_EXCEEDED = "API rate limit exceeded" + PANDOC_NOT_INSTALLED = 'Pandoc is not installed on the server. Please contact your administrator for assistance.' + INCORRECT_FORMAT = lambda err='': f'Invalid format. Please use the correct format{err}' + RATE_LIMIT_EXCEEDED = 'API rate limit exceeded' - MODEL_NOT_FOUND = lambda name="": f"Model '{name}' was not found" - OPENAI_NOT_FOUND = lambda name="": "OpenAI API was not found" - OLLAMA_NOT_FOUND = "WebUI could not connect to Ollama" - CREATE_API_KEY_ERROR = "Oops! Something went wrong while creating your API key. Please try again later. If the issue persists, contact support for assistance." - API_KEY_CREATION_NOT_ALLOWED = "API key creation is not allowed in the environment." + MODEL_NOT_FOUND = lambda name='': f"Model '{name}' was not found" + OPENAI_NOT_FOUND = lambda name='': 'OpenAI API was not found' + OLLAMA_NOT_FOUND = 'WebUI could not connect to Ollama' + CREATE_API_KEY_ERROR = 'Oops! Something went wrong while creating your API key. Please try again later. If the issue persists, contact support for assistance.' + API_KEY_CREATION_NOT_ALLOWED = 'API key creation is not allowed in the environment.' - EMPTY_CONTENT = "The content provided is empty. Please ensure that there is text or data present before proceeding." + EMPTY_CONTENT = 'The content provided is empty. Please ensure that there is text or data present before proceeding.' - DB_NOT_SQLITE = "This feature is only available when running with SQLite databases." + DB_NOT_SQLITE = 'This feature is only available when running with SQLite databases.' - INVALID_URL = ( - "Oops! The URL you provided is invalid. Please double-check and try again." - ) + INVALID_URL = 'Oops! The URL you provided is invalid. Please double-check and try again.' - WEB_SEARCH_ERROR = ( - lambda err="": f"{err if err else 'Oops! Something went wrong while searching the web.'}" - ) + WEB_SEARCH_ERROR = lambda err='': f'{err if err else "Oops! Something went wrong while searching the web."}' - OLLAMA_API_DISABLED = ( - "The Ollama API is disabled. Please enable it to use this feature." - ) + OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.' - FILE_TOO_LARGE = ( - lambda size="": f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}." + FILE_TOO_LARGE = lambda size='': ( + f"Oops! The file you're trying to upload is too large. Please upload a file that is less than {size}." ) - DUPLICATE_CONTENT = ( - "Duplicate content detected. Please provide unique content to proceed." + DUPLICATE_CONTENT = 'Duplicate content detected. Please provide unique content to proceed.' + FILE_NOT_PROCESSED = ( + 'Extracted content is not available for this file. Please ensure that the file is processed before proceeding.' ) - FILE_NOT_PROCESSED = "Extracted content is not available for this file. Please ensure that the file is processed before proceeding." - INVALID_PASSWORD = lambda err="": ( - err if err else "The password does not meet the required validation criteria." - ) + INVALID_PASSWORD = lambda err='': err if err else 'The password does not meet the required validation criteria.' class TASKS(str, Enum): def __str__(self) -> str: return super().__str__() - DEFAULT = lambda task="": f"{task if task else 'generation'}" - TITLE_GENERATION = "title_generation" - FOLLOW_UP_GENERATION = "follow_up_generation" - TAGS_GENERATION = "tags_generation" - EMOJI_GENERATION = "emoji_generation" - QUERY_GENERATION = "query_generation" - IMAGE_PROMPT_GENERATION = "image_prompt_generation" - AUTOCOMPLETE_GENERATION = "autocomplete_generation" - FUNCTION_CALLING = "function_calling" - MOA_RESPONSE_GENERATION = "moa_response_generation" + DEFAULT = lambda task='': f'{task if task else "generation"}' + TITLE_GENERATION = 'title_generation' + FOLLOW_UP_GENERATION = 'follow_up_generation' + TAGS_GENERATION = 'tags_generation' + EMOJI_GENERATION = 'emoji_generation' + QUERY_GENERATION = 'query_generation' + IMAGE_PROMPT_GENERATION = 'image_prompt_generation' + AUTOCOMPLETE_GENERATION = 'autocomplete_generation' + FUNCTION_CALLING = 'function_calling' + MOA_RESPONSE_GENERATION = 'moa_response_generation' diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 2dd195f0c5..5f43a51b1c 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -37,37 +37,34 @@ try: from dotenv import find_dotenv, load_dotenv - load_dotenv(find_dotenv(str(BASE_DIR / ".env"))) + load_dotenv(find_dotenv(str(BASE_DIR / '.env'))) except ImportError: - print("dotenv not installed, skipping...") + print('dotenv not installed, skipping...') -DOCKER = os.environ.get("DOCKER", "False").lower() == "true" +DOCKER = os.environ.get('DOCKER', 'False').lower() == 'true' # device type embedding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance -USE_CUDA = os.environ.get("USE_CUDA_DOCKER", "false") +USE_CUDA = os.environ.get('USE_CUDA_DOCKER', 'false') -if USE_CUDA.lower() == "true": +if USE_CUDA.lower() == 'true': try: import torch - assert torch.cuda.is_available(), "CUDA not available" - DEVICE_TYPE = "cuda" + assert torch.cuda.is_available(), 'CUDA not available' + DEVICE_TYPE = 'cuda' except Exception as e: - cuda_error = ( - "Error when testing CUDA but USE_CUDA_DOCKER is true. " - f"Resetting USE_CUDA_DOCKER to false: {e}" - ) - os.environ["USE_CUDA_DOCKER"] = "false" - USE_CUDA = "false" - DEVICE_TYPE = "cpu" + cuda_error = f'Error when testing CUDA but USE_CUDA_DOCKER is true. Resetting USE_CUDA_DOCKER to false: {e}' + os.environ['USE_CUDA_DOCKER'] = 'false' + USE_CUDA = 'false' + DEVICE_TYPE = 'cpu' else: - DEVICE_TYPE = "cpu" + DEVICE_TYPE = 'cpu' try: import torch if torch.backends.mps.is_available() and torch.backends.mps.is_built(): - DEVICE_TYPE = "mps" + DEVICE_TYPE = 'mps' except Exception: pass @@ -76,11 +73,11 @@ #################################### _LEVEL_MAP = { - "DEBUG": "debug", - "INFO": "info", - "WARNING": "warn", - "ERROR": "error", - "CRITICAL": "fatal", + 'DEBUG': 'debug', + 'INFO': 'info', + 'WARNING': 'warn', + 'ERROR': 'error', + 'CRITICAL': 'fatal', } @@ -89,134 +86,128 @@ class JSONFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: log_entry: dict[str, Any] = { - "ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat( - timespec="milliseconds" - ), - "level": _LEVEL_MAP.get(record.levelname, record.levelname.lower()), - "msg": record.getMessage(), - "caller": record.name, + 'ts': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec='milliseconds'), + 'level': _LEVEL_MAP.get(record.levelname, record.levelname.lower()), + 'msg': record.getMessage(), + 'caller': record.name, } if record.exc_info and record.exc_info[0] is not None: - log_entry["error"] = "".join( - traceback.format_exception(*record.exc_info) - ).rstrip() + log_entry['error'] = ''.join(traceback.format_exception(*record.exc_info)).rstrip() elif record.exc_text: - log_entry["error"] = record.exc_text + log_entry['error'] = record.exc_text if record.stack_info: - log_entry["stacktrace"] = record.stack_info + log_entry['stacktrace'] = record.stack_info return json.dumps(log_entry, ensure_ascii=False, default=str) -LOG_FORMAT = os.environ.get("LOG_FORMAT", "").lower() +LOG_FORMAT = os.environ.get('LOG_FORMAT', '').lower() -GLOBAL_LOG_LEVEL = os.environ.get("GLOBAL_LOG_LEVEL", "").upper() +GLOBAL_LOG_LEVEL = os.environ.get('GLOBAL_LOG_LEVEL', '').upper() if GLOBAL_LOG_LEVEL in logging.getLevelNamesMapping(): - if LOG_FORMAT == "json": + if LOG_FORMAT == 'json': _handler = logging.StreamHandler(sys.stdout) _handler.setFormatter(JSONFormatter()) logging.basicConfig(handlers=[_handler], level=GLOBAL_LOG_LEVEL, force=True) else: logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL, force=True) else: - GLOBAL_LOG_LEVEL = "INFO" + GLOBAL_LOG_LEVEL = 'INFO' log = logging.getLogger(__name__) -log.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") +log.info(f'GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}') -if "cuda_error" in locals(): +if 'cuda_error' in locals(): log.exception(cuda_error) del cuda_error SRC_LOG_LEVELS = {} # Legacy variable, do not remove -WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI") -if WEBUI_NAME != "Open WebUI": - WEBUI_NAME += " (Open WebUI)" +WEBUI_NAME = os.environ.get('WEBUI_NAME', 'Open WebUI') +if WEBUI_NAME != 'Open WebUI': + WEBUI_NAME += ' (Open WebUI)' -WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png" +WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png' -TRUSTED_SIGNATURE_KEY = os.environ.get("TRUSTED_SIGNATURE_KEY", "") +TRUSTED_SIGNATURE_KEY = os.environ.get('TRUSTED_SIGNATURE_KEY', '') #################################### # ENV (dev,test,prod) #################################### -ENV = os.environ.get("ENV", "dev") +ENV = os.environ.get('ENV', 'dev') -FROM_INIT_PY = os.environ.get("FROM_INIT_PY", "False").lower() == "true" +FROM_INIT_PY = os.environ.get('FROM_INIT_PY', 'False').lower() == 'true' if FROM_INIT_PY: - PACKAGE_DATA = {"version": importlib.metadata.version("open-webui")} + PACKAGE_DATA = {'version': importlib.metadata.version('open-webui')} else: try: - PACKAGE_DATA = json.loads((BASE_DIR / "package.json").read_text()) + PACKAGE_DATA = json.loads((BASE_DIR / 'package.json').read_text()) except Exception: - PACKAGE_DATA = {"version": "0.0.0"} + PACKAGE_DATA = {'version': '0.0.0'} -VERSION = PACKAGE_DATA["version"] +VERSION = PACKAGE_DATA['version'] -DEPLOYMENT_ID = os.environ.get("DEPLOYMENT_ID", "") -INSTANCE_ID = os.environ.get("INSTANCE_ID", str(uuid4())) +DEPLOYMENT_ID = os.environ.get('DEPLOYMENT_ID', '') +INSTANCE_ID = os.environ.get('INSTANCE_ID', str(uuid4())) -ENABLE_DB_MIGRATIONS = os.environ.get("ENABLE_DB_MIGRATIONS", "True").lower() == "true" +ENABLE_DB_MIGRATIONS = os.environ.get('ENABLE_DB_MIGRATIONS', 'True').lower() == 'true' # Function to parse each section def parse_section(section): items = [] - for li in section.find_all("li"): + for li in section.find_all('li'): # Extract raw HTML string raw_html = str(li) # Extract text without HTML tags - text = li.get_text(separator=" ", strip=True) + text = li.get_text(separator=' ', strip=True) # Split into title and content - parts = text.split(": ", 1) - title = parts[0].strip() if len(parts) > 1 else "" + parts = text.split(': ', 1) + title = parts[0].strip() if len(parts) > 1 else '' content = parts[1].strip() if len(parts) > 1 else text - items.append({"title": title, "content": content, "raw": raw_html}) + items.append({'title': title, 'content': content, 'raw': raw_html}) return items try: - changelog_path = BASE_DIR / "CHANGELOG_EXTRA.md" - with open(str(changelog_path.absolute()), "r", encoding="utf8") as file: + changelog_path = BASE_DIR / 'CHANGELOG_EXTRA.md' + with open(str(changelog_path.absolute()), 'r', encoding='utf8') as file: changelog_content = file.read() except Exception: - changelog_content = ( - pkgutil.get_data("open_webui", "CHANGELOG_EXTRA.md") or b"" - ).decode() + changelog_content = (pkgutil.get_data('open_webui', 'CHANGELOG_EXTRA.md') or b'').decode() # Convert markdown content to HTML html_content = markdown.markdown(changelog_content) # Parse the HTML content -soup = BeautifulSoup(html_content, "html.parser") +soup = BeautifulSoup(html_content, 'html.parser') # Initialize JSON structure changelog_json = {} # Iterate over each version -for version in soup.find_all("h2"): - version_number = version.get_text().strip().split(" - ")[0][1:-1] # Remove brackets - date = version.get_text().strip().split(" - ")[1] +for version in soup.find_all('h2'): + version_number = version.get_text().strip().split(' - ')[0][1:-1] # Remove brackets + date = version.get_text().strip().split(' - ')[1] - version_data = {"date": date} + version_data = {'date': date} # Find the next sibling that is a h3 tag (section title) current = version.find_next_sibling() - while current and current.name != "h2": - if current.name == "h3": + while current and current.name != 'h2': + if current.name == 'h3': section_title = current.get_text().lower() # e.g., "added", "fixed" - section_items = parse_section(current.find_next_sibling("ul")) + section_items = parse_section(current.find_next_sibling('ul')) version_data[section_title] = section_items # Move to the next element @@ -230,65 +221,51 @@ def parse_section(section): # SAFE_MODE #################################### -SAFE_MODE = os.environ.get("SAFE_MODE", "false").lower() == "true" +SAFE_MODE = os.environ.get('SAFE_MODE', 'false').lower() == 'true' #################################### # ENABLE_FORWARD_USER_INFO_HEADERS #################################### -ENABLE_FORWARD_USER_INFO_HEADERS = ( - os.environ.get("ENABLE_FORWARD_USER_INFO_HEADERS", "False").lower() == "true" -) +ENABLE_FORWARD_USER_INFO_HEADERS = os.environ.get('ENABLE_FORWARD_USER_INFO_HEADERS', 'False').lower() == 'true' # Header names for user info forwarding (customizable via environment variables) -FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get( - "FORWARD_USER_INFO_HEADER_USER_NAME", "X-OpenWebUI-User-Name" -) -FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get( - "FORWARD_USER_INFO_HEADER_USER_ID", "X-OpenWebUI-User-Id" -) -FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get( - "FORWARD_USER_INFO_HEADER_USER_EMAIL", "X-OpenWebUI-User-Email" -) -FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get( - "FORWARD_USER_INFO_HEADER_USER_ROLE", "X-OpenWebUI-User-Role" -) +FORWARD_USER_INFO_HEADER_USER_NAME = os.environ.get('FORWARD_USER_INFO_HEADER_USER_NAME', 'X-OpenWebUI-User-Name') +FORWARD_USER_INFO_HEADER_USER_ID = os.environ.get('FORWARD_USER_INFO_HEADER_USER_ID', 'X-OpenWebUI-User-Id') +FORWARD_USER_INFO_HEADER_USER_EMAIL = os.environ.get('FORWARD_USER_INFO_HEADER_USER_EMAIL', 'X-OpenWebUI-User-Email') +FORWARD_USER_INFO_HEADER_USER_ROLE = os.environ.get('FORWARD_USER_INFO_HEADER_USER_ROLE', 'X-OpenWebUI-User-Role') # Header name for chat ID forwarding (customizable via environment variable) FORWARD_SESSION_INFO_HEADER_MESSAGE_ID = os.environ.get( - "FORWARD_SESSION_INFO_HEADER_MESSAGE_ID", "X-OpenWebUI-Message-Id" -) -FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get( - "FORWARD_SESSION_INFO_HEADER_CHAT_ID", "X-OpenWebUI-Chat-Id" + 'FORWARD_SESSION_INFO_HEADER_MESSAGE_ID', 'X-OpenWebUI-Message-Id' ) +FORWARD_SESSION_INFO_HEADER_CHAT_ID = os.environ.get('FORWARD_SESSION_INFO_HEADER_CHAT_ID', 'X-OpenWebUI-Chat-Id') # Experimental feature, may be removed in future -ENABLE_STAR_SESSIONS_MIDDLEWARE = ( - os.environ.get("ENABLE_STAR_SESSIONS_MIDDLEWARE", "False").lower() == "true" -) +ENABLE_STAR_SESSIONS_MIDDLEWARE = os.environ.get('ENABLE_STAR_SESSIONS_MIDDLEWARE', 'False').lower() == 'true' -ENABLE_EASTER_EGGS = os.environ.get("ENABLE_EASTER_EGGS", "True").lower() == "true" +ENABLE_EASTER_EGGS = os.environ.get('ENABLE_EASTER_EGGS', 'True').lower() == 'true' #################################### # WEBUI_BUILD_HASH #################################### -WEBUI_BUILD_HASH = os.environ.get("WEBUI_BUILD_HASH", "dev-build") +WEBUI_BUILD_HASH = os.environ.get('WEBUI_BUILD_HASH', 'dev-build') #################################### # DATA/FRONTEND BUILD DIR #################################### -DATA_DIR = Path(os.getenv("DATA_DIR", BACKEND_DIR / "data")).resolve() +DATA_DIR = Path(os.getenv('DATA_DIR', BACKEND_DIR / 'data')).resolve() if FROM_INIT_PY: - NEW_DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data")).resolve() + NEW_DATA_DIR = Path(os.getenv('DATA_DIR', OPEN_WEBUI_DIR / 'data')).resolve() NEW_DATA_DIR.mkdir(parents=True, exist_ok=True) # Check if the data directory exists in the package directory if DATA_DIR.exists() and DATA_DIR != NEW_DATA_DIR: - log.info(f"Moving {DATA_DIR} to {NEW_DATA_DIR}") + log.info(f'Moving {DATA_DIR} to {NEW_DATA_DIR}') for item in DATA_DIR.iterdir(): dest = NEW_DATA_DIR / item.name if item.is_dir(): @@ -297,69 +274,69 @@ def parse_section(section): shutil.copy2(item, dest) # Zip the data directory - shutil.make_archive(DATA_DIR.parent / "open_webui_data", "zip", DATA_DIR) + shutil.make_archive(DATA_DIR.parent / 'open_webui_data', 'zip', DATA_DIR) # Remove the old data directory shutil.rmtree(DATA_DIR) - DATA_DIR = Path(os.getenv("DATA_DIR", OPEN_WEBUI_DIR / "data")) + DATA_DIR = Path(os.getenv('DATA_DIR', OPEN_WEBUI_DIR / 'data')) -STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")) +STATIC_DIR = Path(os.getenv('STATIC_DIR', OPEN_WEBUI_DIR / 'static')) -FONTS_DIR = Path(os.getenv("FONTS_DIR", OPEN_WEBUI_DIR / "static" / "fonts")) +FONTS_DIR = Path(os.getenv('FONTS_DIR', OPEN_WEBUI_DIR / 'static' / 'fonts')) -FRONTEND_BUILD_DIR = Path(os.getenv("FRONTEND_BUILD_DIR", BASE_DIR / "build")).resolve() +FRONTEND_BUILD_DIR = Path(os.getenv('FRONTEND_BUILD_DIR', BASE_DIR / 'build')).resolve() if FROM_INIT_PY: - FRONTEND_BUILD_DIR = Path( - os.getenv("FRONTEND_BUILD_DIR", OPEN_WEBUI_DIR / "frontend") - ).resolve() + FRONTEND_BUILD_DIR = Path(os.getenv('FRONTEND_BUILD_DIR', OPEN_WEBUI_DIR / 'frontend')).resolve() #################################### # Database #################################### # Check if the file exists -if os.path.exists(f"{DATA_DIR}/ollama.db"): +if os.path.exists(f'{DATA_DIR}/ollama.db'): # Rename the file - os.rename(f"{DATA_DIR}/ollama.db", f"{DATA_DIR}/webui.db") - log.info("Database migrated from Ollama-WebUI successfully.") + os.rename(f'{DATA_DIR}/ollama.db', f'{DATA_DIR}/webui.db') + log.info('Database migrated from Ollama-WebUI successfully.') else: pass -DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db") +DATABASE_URL = os.environ.get('DATABASE_URL', f'sqlite:///{DATA_DIR}/webui.db') -DATABASE_TYPE = os.environ.get("DATABASE_TYPE") -DATABASE_USER = os.environ.get("DATABASE_USER") -DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD") +DATABASE_TYPE = os.environ.get('DATABASE_TYPE') +DATABASE_USER = os.environ.get('DATABASE_USER') +DATABASE_PASSWORD = os.environ.get('DATABASE_PASSWORD') -DATABASE_CRED = "" +DATABASE_CRED = '' if DATABASE_USER: - DATABASE_CRED += f"{DATABASE_USER}" + DATABASE_CRED += f'{DATABASE_USER}' if DATABASE_PASSWORD: - DATABASE_CRED += f":{DATABASE_PASSWORD}" + DATABASE_CRED += f':{DATABASE_PASSWORD}' DB_VARS = { - "db_type": DATABASE_TYPE, - "db_cred": DATABASE_CRED, - "db_host": os.environ.get("DATABASE_HOST"), - "db_port": os.environ.get("DATABASE_PORT"), - "db_name": os.environ.get("DATABASE_NAME"), + 'db_type': DATABASE_TYPE, + 'db_cred': DATABASE_CRED, + 'db_host': os.environ.get('DATABASE_HOST'), + 'db_port': os.environ.get('DATABASE_PORT'), + 'db_name': os.environ.get('DATABASE_NAME'), } if all(DB_VARS.values()): - DATABASE_URL = f"{DB_VARS['db_type']}://{DB_VARS['db_cred']}@{DB_VARS['db_host']}:{DB_VARS['db_port']}/{DB_VARS['db_name']}" -elif DATABASE_TYPE == "sqlite+sqlcipher" and not os.environ.get("DATABASE_URL"): + DATABASE_URL = ( + f'{DB_VARS["db_type"]}://{DB_VARS["db_cred"]}@{DB_VARS["db_host"]}:{DB_VARS["db_port"]}/{DB_VARS["db_name"]}' + ) +elif DATABASE_TYPE == 'sqlite+sqlcipher' and not os.environ.get('DATABASE_URL'): # Handle SQLCipher with local file when DATABASE_URL wasn't explicitly set - DATABASE_URL = f"sqlite+sqlcipher:///{DATA_DIR}/webui.db" + DATABASE_URL = f'sqlite+sqlcipher:///{DATA_DIR}/webui.db' # Replace the postgres:// with postgresql:// -if "postgres://" in DATABASE_URL: - DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://") +if 'postgres://' in DATABASE_URL: + DATABASE_URL = DATABASE_URL.replace('postgres://', 'postgresql://') -DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None) +DATABASE_SCHEMA = os.environ.get('DATABASE_SCHEMA', None) -DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", None) +DATABASE_POOL_SIZE = os.environ.get('DATABASE_POOL_SIZE', None) if DATABASE_POOL_SIZE != None: try: @@ -367,9 +344,9 @@ def parse_section(section): except Exception: DATABASE_POOL_SIZE = None -DATABASE_POOL_MAX_OVERFLOW = os.environ.get("DATABASE_POOL_MAX_OVERFLOW", 0) +DATABASE_POOL_MAX_OVERFLOW = os.environ.get('DATABASE_POOL_MAX_OVERFLOW', 0) -if DATABASE_POOL_MAX_OVERFLOW == "": +if DATABASE_POOL_MAX_OVERFLOW == '': DATABASE_POOL_MAX_OVERFLOW = 0 else: try: @@ -377,9 +354,9 @@ def parse_section(section): except Exception: DATABASE_POOL_MAX_OVERFLOW = 0 -DATABASE_POOL_TIMEOUT = os.environ.get("DATABASE_POOL_TIMEOUT", 30) +DATABASE_POOL_TIMEOUT = os.environ.get('DATABASE_POOL_TIMEOUT', 30) -if DATABASE_POOL_TIMEOUT == "": +if DATABASE_POOL_TIMEOUT == '': DATABASE_POOL_TIMEOUT = 30 else: try: @@ -387,9 +364,9 @@ def parse_section(section): except Exception: DATABASE_POOL_TIMEOUT = 30 -DATABASE_POOL_RECYCLE = os.environ.get("DATABASE_POOL_RECYCLE", 3600) +DATABASE_POOL_RECYCLE = os.environ.get('DATABASE_POOL_RECYCLE', 3600) -if DATABASE_POOL_RECYCLE == "": +if DATABASE_POOL_RECYCLE == '': DATABASE_POOL_RECYCLE = 3600 else: try: @@ -397,57 +374,43 @@ def parse_section(section): except Exception: DATABASE_POOL_RECYCLE = 3600 -DATABASE_ENABLE_SQLITE_WAL = ( - os.environ.get("DATABASE_ENABLE_SQLITE_WAL", "False").lower() == "true" -) +DATABASE_ENABLE_SQLITE_WAL = os.environ.get('DATABASE_ENABLE_SQLITE_WAL', 'False').lower() == 'true' -DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get( - "DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL", None -) +DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = os.environ.get('DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL', None) if DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL is not None: try: - DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float( - DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL - ) + DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = float(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) except Exception: DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL = 0.0 # When enabled, get_db_context reuses existing sessions; set to False to always create new sessions -DATABASE_ENABLE_SESSION_SHARING = ( - os.environ.get("DATABASE_ENABLE_SESSION_SHARING", "False").lower() == "true" -) +DATABASE_ENABLE_SESSION_SHARING = os.environ.get('DATABASE_ENABLE_SESSION_SHARING', 'False').lower() == 'true' # Enable public visibility of active user count (when disabled, only admins can see it) -ENABLE_PUBLIC_ACTIVE_USERS_COUNT = ( - os.environ.get("ENABLE_PUBLIC_ACTIVE_USERS_COUNT", "True").lower() == "true" -) +ENABLE_PUBLIC_ACTIVE_USERS_COUNT = os.environ.get('ENABLE_PUBLIC_ACTIVE_USERS_COUNT', 'True').lower() == 'true' -RESET_CONFIG_ON_START = ( - os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" -) +RESET_CONFIG_ON_START = os.environ.get('RESET_CONFIG_ON_START', 'False').lower() == 'true' -ENABLE_REALTIME_CHAT_SAVE = ( - os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true" -) +ENABLE_REALTIME_CHAT_SAVE = os.environ.get('ENABLE_REALTIME_CHAT_SAVE', 'False').lower() == 'true' -ENABLE_QUERIES_CACHE = os.environ.get("ENABLE_QUERIES_CACHE", "False").lower() == "true" +ENABLE_QUERIES_CACHE = os.environ.get('ENABLE_QUERIES_CACHE', 'False').lower() == 'true' -RAG_SYSTEM_CONTEXT = os.environ.get("RAG_SYSTEM_CONTEXT", "False").lower() == "true" +RAG_SYSTEM_CONTEXT = os.environ.get('RAG_SYSTEM_CONTEXT', 'False').lower() == 'true' #################################### # REDIS #################################### -REDIS_URL = os.environ.get("REDIS_URL", "") -REDIS_CLUSTER = os.environ.get("REDIS_CLUSTER", "False").lower() == "true" +REDIS_URL = os.environ.get('REDIS_URL', '') +REDIS_CLUSTER = os.environ.get('REDIS_CLUSTER', 'False').lower() == 'true' -REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui") +REDIS_KEY_PREFIX = os.environ.get('REDIS_KEY_PREFIX', 'open-webui') -REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "") -REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379") +REDIS_SENTINEL_HOSTS = os.environ.get('REDIS_SENTINEL_HOSTS', '') +REDIS_SENTINEL_PORT = os.environ.get('REDIS_SENTINEL_PORT', '26379') # Maximum number of retries for Redis operations when using Sentinel fail-over -REDIS_SENTINEL_MAX_RETRY_COUNT = os.environ.get("REDIS_SENTINEL_MAX_RETRY_COUNT", "2") +REDIS_SENTINEL_MAX_RETRY_COUNT = os.environ.get('REDIS_SENTINEL_MAX_RETRY_COUNT', '2') try: REDIS_SENTINEL_MAX_RETRY_COUNT = int(REDIS_SENTINEL_MAX_RETRY_COUNT) if REDIS_SENTINEL_MAX_RETRY_COUNT < 1: @@ -456,15 +419,15 @@ def parse_section(section): REDIS_SENTINEL_MAX_RETRY_COUNT = 2 -REDIS_SOCKET_CONNECT_TIMEOUT = os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "") +REDIS_SOCKET_CONNECT_TIMEOUT = os.environ.get('REDIS_SOCKET_CONNECT_TIMEOUT', '') try: REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT) except ValueError: REDIS_SOCKET_CONNECT_TIMEOUT = None -REDIS_RECONNECT_DELAY = os.environ.get("REDIS_RECONNECT_DELAY", "") +REDIS_RECONNECT_DELAY = os.environ.get('REDIS_RECONNECT_DELAY', '') -if REDIS_RECONNECT_DELAY == "": +if REDIS_RECONNECT_DELAY == '': REDIS_RECONNECT_DELAY = None else: try: @@ -479,27 +442,23 @@ def parse_section(section): #################################### # Number of uvicorn worker processes for handling requests -UVICORN_WORKERS = os.environ.get("UVICORN_WORKERS", "1") +UVICORN_WORKERS = os.environ.get('UVICORN_WORKERS', '1') try: UVICORN_WORKERS = int(UVICORN_WORKERS) if UVICORN_WORKERS < 1: UVICORN_WORKERS = 1 except ValueError: UVICORN_WORKERS = 1 - log.info(f"Invalid UVICORN_WORKERS value, defaulting to {UVICORN_WORKERS}") + log.info(f'Invalid UVICORN_WORKERS value, defaulting to {UVICORN_WORKERS}') #################################### # WEBUI_AUTH (Required for security) #################################### -WEBUI_AUTH = os.environ.get("WEBUI_AUTH", "True").lower() == "true" +WEBUI_AUTH = os.environ.get('WEBUI_AUTH', 'True').lower() == 'true' -ENABLE_INITIAL_ADMIN_SIGNUP = ( - os.environ.get("ENABLE_INITIAL_ADMIN_SIGNUP", "False").lower() == "true" -) -ENABLE_SIGNUP_PASSWORD_CONFIRMATION = ( - os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true" -) +ENABLE_INITIAL_ADMIN_SIGNUP = os.environ.get('ENABLE_INITIAL_ADMIN_SIGNUP', 'False').lower() == 'true' +ENABLE_SIGNUP_PASSWORD_CONFIRMATION = os.environ.get('ENABLE_SIGNUP_PASSWORD_CONFIRMATION', 'False').lower() == 'true' #################################### # Admin Account Runtime Creation @@ -507,164 +466,132 @@ def parse_section(section): # Optional env vars for creating an admin account on startup # Useful for headless/automated deployments -WEBUI_ADMIN_EMAIL = os.environ.get("WEBUI_ADMIN_EMAIL", "") -WEBUI_ADMIN_PASSWORD = os.environ.get("WEBUI_ADMIN_PASSWORD", "") -WEBUI_ADMIN_NAME = os.environ.get("WEBUI_ADMIN_NAME", "Admin") +WEBUI_ADMIN_EMAIL = os.environ.get('WEBUI_ADMIN_EMAIL', '') +WEBUI_ADMIN_PASSWORD = os.environ.get('WEBUI_ADMIN_PASSWORD', '') +WEBUI_ADMIN_NAME = os.environ.get('WEBUI_ADMIN_NAME', 'Admin') -WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( - "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None -) -WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get("WEBUI_AUTH_TRUSTED_NAME_HEADER", None) -WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get( - "WEBUI_AUTH_TRUSTED_GROUPS_HEADER", None -) +WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_EMAIL_HEADER', None) +WEBUI_AUTH_TRUSTED_NAME_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_NAME_HEADER', None) +WEBUI_AUTH_TRUSTED_GROUPS_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_GROUPS_HEADER', None) +WEBUI_AUTH_TRUSTED_ROLE_HEADER = os.environ.get('WEBUI_AUTH_TRUSTED_ROLE_HEADER', None) -ENABLE_PASSWORD_VALIDATION = ( - os.environ.get("ENABLE_PASSWORD_VALIDATION", "False").lower() == "true" -) +ENABLE_PASSWORD_VALIDATION = os.environ.get('ENABLE_PASSWORD_VALIDATION', 'False').lower() == 'true' PASSWORD_VALIDATION_REGEX_PATTERN = os.environ.get( - "PASSWORD_VALIDATION_REGEX_PATTERN", - r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$", + 'PASSWORD_VALIDATION_REGEX_PATTERN', + r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$', ) try: - PASSWORD_VALIDATION_REGEX_PATTERN = rf"{PASSWORD_VALIDATION_REGEX_PATTERN}" + PASSWORD_VALIDATION_REGEX_PATTERN = rf'{PASSWORD_VALIDATION_REGEX_PATTERN}' PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(PASSWORD_VALIDATION_REGEX_PATTERN) except Exception as e: - log.error(f"Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}") - PASSWORD_VALIDATION_REGEX_PATTERN = re.compile( - r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$" - ) + log.error(f'Invalid PASSWORD_VALIDATION_REGEX_PATTERN: {e}') + PASSWORD_VALIDATION_REGEX_PATTERN = re.compile(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^\w\s]).{8,}$') -PASSWORD_VALIDATION_HINT = os.environ.get("PASSWORD_VALIDATION_HINT", "") +PASSWORD_VALIDATION_HINT = os.environ.get('PASSWORD_VALIDATION_HINT', '') -BYPASS_MODEL_ACCESS_CONTROL = ( - os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true" -) +BYPASS_MODEL_ACCESS_CONTROL = os.environ.get('BYPASS_MODEL_ACCESS_CONTROL', 'False').lower() == 'true' -WEBUI_AUTH_SIGNOUT_REDIRECT_URL = os.environ.get( - "WEBUI_AUTH_SIGNOUT_REDIRECT_URL", None -) +WEBUI_AUTH_SIGNOUT_REDIRECT_URL = os.environ.get('WEBUI_AUTH_SIGNOUT_REDIRECT_URL', None) #################################### # WEBUI_SECRET_KEY #################################### WEBUI_SECRET_KEY = os.environ.get( - "WEBUI_SECRET_KEY", - os.environ.get( - "WEBUI_JWT_SECRET_KEY", "t0p-s3cr3t" - ), # DEPRECATED: remove at next major version + 'WEBUI_SECRET_KEY', + os.environ.get('WEBUI_JWT_SECRET_KEY', 't0p-s3cr3t'), # DEPRECATED: remove at next major version ) -WEBUI_SESSION_COOKIE_SAME_SITE = os.environ.get("WEBUI_SESSION_COOKIE_SAME_SITE", "lax") +WEBUI_SESSION_COOKIE_SAME_SITE = os.environ.get('WEBUI_SESSION_COOKIE_SAME_SITE', 'lax') -WEBUI_SESSION_COOKIE_SECURE = ( - os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false").lower() == "true" -) +WEBUI_SESSION_COOKIE_SECURE = os.environ.get('WEBUI_SESSION_COOKIE_SECURE', 'false').lower() == 'true' -WEBUI_AUTH_COOKIE_SAME_SITE = os.environ.get( - "WEBUI_AUTH_COOKIE_SAME_SITE", WEBUI_SESSION_COOKIE_SAME_SITE -) +WEBUI_AUTH_COOKIE_SAME_SITE = os.environ.get('WEBUI_AUTH_COOKIE_SAME_SITE', WEBUI_SESSION_COOKIE_SAME_SITE) WEBUI_AUTH_COOKIE_SECURE = ( os.environ.get( - "WEBUI_AUTH_COOKIE_SECURE", - os.environ.get("WEBUI_SESSION_COOKIE_SECURE", "false"), + 'WEBUI_AUTH_COOKIE_SECURE', + os.environ.get('WEBUI_SESSION_COOKIE_SECURE', 'false'), ).lower() - == "true" + == 'true' ) -if WEBUI_AUTH and WEBUI_SECRET_KEY == "": +if WEBUI_AUTH and WEBUI_SECRET_KEY == '': raise ValueError(ERROR_MESSAGES.ENV_VAR_NOT_FOUND) -ENABLE_COMPRESSION_MIDDLEWARE = ( - os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true" -) +ENABLE_COMPRESSION_MIDDLEWARE = os.environ.get('ENABLE_COMPRESSION_MIDDLEWARE', 'True').lower() == 'true' #################################### # OAUTH Configuration #################################### -ENABLE_OAUTH_EMAIL_FALLBACK = ( - os.environ.get("ENABLE_OAUTH_EMAIL_FALLBACK", "False").lower() == "true" -) +ENABLE_OAUTH_EMAIL_FALLBACK = os.environ.get('ENABLE_OAUTH_EMAIL_FALLBACK', 'False').lower() == 'true' -ENABLE_OAUTH_ID_TOKEN_COOKIE = ( - os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true" -) +ENABLE_OAUTH_ID_TOKEN_COOKIE = os.environ.get('ENABLE_OAUTH_ID_TOKEN_COOKIE', 'True').lower() == 'true' -OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get( - "OAUTH_CLIENT_INFO_ENCRYPTION_KEY", WEBUI_SECRET_KEY -) +OAUTH_CLIENT_INFO_ENCRYPTION_KEY = os.environ.get('OAUTH_CLIENT_INFO_ENCRYPTION_KEY', WEBUI_SECRET_KEY) -OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get( - "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY -) +OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get('OAUTH_SESSION_TOKEN_ENCRYPTION_KEY', WEBUI_SECRET_KEY) # Maximum number of concurrent OAuth sessions per user per provider # This prevents unbounded session growth while allowing multi-device usage -OAUTH_MAX_SESSIONS_PER_USER = int(os.environ.get("OAUTH_MAX_SESSIONS_PER_USER", "10")) +OAUTH_MAX_SESSIONS_PER_USER = int(os.environ.get('OAUTH_MAX_SESSIONS_PER_USER', '10')) # Token Exchange Configuration # Allows external apps to exchange OAuth tokens for OpenWebUI tokens -ENABLE_OAUTH_TOKEN_EXCHANGE = ( - os.environ.get("ENABLE_OAUTH_TOKEN_EXCHANGE", "False").lower() == "true" -) +ENABLE_OAUTH_TOKEN_EXCHANGE = os.environ.get('ENABLE_OAUTH_TOKEN_EXCHANGE', 'False').lower() == 'true' #################################### # SCIM Configuration #################################### -ENABLE_SCIM = ( - os.environ.get("ENABLE_SCIM", os.environ.get("SCIM_ENABLED", "False")).lower() - == "true" -) -SCIM_TOKEN = os.environ.get("SCIM_TOKEN", "") -SCIM_AUTH_PROVIDER = os.environ.get("SCIM_AUTH_PROVIDER", "") +ENABLE_SCIM = os.environ.get('ENABLE_SCIM', os.environ.get('SCIM_ENABLED', 'False')).lower() == 'true' +SCIM_TOKEN = os.environ.get('SCIM_TOKEN', '') +SCIM_AUTH_PROVIDER = os.environ.get('SCIM_AUTH_PROVIDER', '') if ENABLE_SCIM and not SCIM_AUTH_PROVIDER: log.warning( - "SCIM is enabled but SCIM_AUTH_PROVIDER is not set. " + 'SCIM is enabled but SCIM_AUTH_PROVIDER is not set. ' "Set SCIM_AUTH_PROVIDER to the OAuth provider name (e.g. 'microsoft', 'oidc') " - "to enable externalId storage." + 'to enable externalId storage.' ) #################################### # LICENSE_KEY #################################### -LICENSE_KEY = os.environ.get("LICENSE_KEY", "") +LICENSE_KEY = os.environ.get('LICENSE_KEY', '') LICENSE_BLOB = None -LICENSE_BLOB_PATH = os.environ.get("LICENSE_BLOB_PATH", DATA_DIR / "l.data") +LICENSE_BLOB_PATH = os.environ.get('LICENSE_BLOB_PATH', DATA_DIR / 'l.data') if LICENSE_BLOB_PATH and os.path.exists(LICENSE_BLOB_PATH): - with open(LICENSE_BLOB_PATH, "rb") as f: + with open(LICENSE_BLOB_PATH, 'rb') as f: LICENSE_BLOB = f.read() -LICENSE_PUBLIC_KEY = os.environ.get("LICENSE_PUBLIC_KEY", "") +LICENSE_PUBLIC_KEY = os.environ.get('LICENSE_PUBLIC_KEY', '') pk = None if LICENSE_PUBLIC_KEY: - pk = serialization.load_pem_public_key(f""" + pk = serialization.load_pem_public_key( + f""" -----BEGIN PUBLIC KEY----- {LICENSE_PUBLIC_KEY} -----END PUBLIC KEY----- -""".encode("utf-8")) +""".encode('utf-8') + ) #################################### # MODELS #################################### -ENABLE_CUSTOM_MODEL_FALLBACK = ( - os.environ.get("ENABLE_CUSTOM_MODEL_FALLBACK", "False").lower() == "true" -) +ENABLE_CUSTOM_MODEL_FALLBACK = os.environ.get('ENABLE_CUSTOM_MODEL_FALLBACK', 'False').lower() == 'true' -MODELS_CACHE_TTL = os.environ.get("MODELS_CACHE_TTL", "1") -if MODELS_CACHE_TTL == "": +MODELS_CACHE_TTL = os.environ.get('MODELS_CACHE_TTL', '1') +if MODELS_CACHE_TTL == '': MODELS_CACHE_TTL = None else: try: @@ -678,30 +605,23 @@ def parse_section(section): #################################### ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION = ( - os.environ.get("ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION", "False").lower() - == "true" + os.environ.get('ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION', 'False').lower() == 'true' ) -CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get( - "CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE", "1" -) +CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get('CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE', '1') -if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == "": +if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == '': CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1 else: try: - CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = int( - CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE - ) + CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = int(CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE) except Exception: CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = 1 -CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get( - "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30" -) +CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get('CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES', '30') -if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "": +if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == '': CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30 else: try: @@ -710,17 +630,20 @@ def parse_section(section): CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30 -CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = os.environ.get( - "CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE", "" -) +# WARNING: Experimental. Only enable if your upstream Responses API endpoint +# supports stateful sessions (i.e. server-side response storage with +# previous_response_id anchoring). Most proxies and third-party endpoints +# are stateless and will break if this is enabled. +ENABLE_RESPONSES_API_STATEFUL = os.environ.get('ENABLE_RESPONSES_API_STATEFUL', 'False').lower() == 'true' -if CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE == "": + +CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = os.environ.get('CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE', '') + +if CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE == '': CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = None else: try: - CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = int( - CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE - ) + CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = int(CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE) except Exception: CHAT_STREAM_RESPONSE_CHUNK_MAX_BUFFER_SIZE = None @@ -729,70 +652,62 @@ def parse_section(section): # WEBSOCKET SUPPORT #################################### -ENABLE_WEBSOCKET_SUPPORT = ( - os.environ.get("ENABLE_WEBSOCKET_SUPPORT", "True").lower() == "true" -) +ENABLE_WEBSOCKET_SUPPORT = os.environ.get('ENABLE_WEBSOCKET_SUPPORT', 'True').lower() == 'true' -WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "") +WEBSOCKET_MANAGER = os.environ.get('WEBSOCKET_MANAGER', '') -WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "") +WEBSOCKET_REDIS_OPTIONS = os.environ.get('WEBSOCKET_REDIS_OPTIONS', '') -if WEBSOCKET_REDIS_OPTIONS == "": +if WEBSOCKET_REDIS_OPTIONS == '': if REDIS_SOCKET_CONNECT_TIMEOUT: - WEBSOCKET_REDIS_OPTIONS = { - "socket_connect_timeout": REDIS_SOCKET_CONNECT_TIMEOUT - } + WEBSOCKET_REDIS_OPTIONS = {'socket_connect_timeout': REDIS_SOCKET_CONNECT_TIMEOUT} else: - log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None") + log.debug('No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None') WEBSOCKET_REDIS_OPTIONS = None else: try: WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS) except Exception: - log.warning("Invalid WEBSOCKET_REDIS_OPTIONS, defaulting to None") + log.warning('Invalid WEBSOCKET_REDIS_OPTIONS, defaulting to None') WEBSOCKET_REDIS_OPTIONS = None -WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL) -WEBSOCKET_REDIS_CLUSTER = ( - os.environ.get("WEBSOCKET_REDIS_CLUSTER", str(REDIS_CLUSTER)).lower() == "true" -) +WEBSOCKET_REDIS_URL = os.environ.get('WEBSOCKET_REDIS_URL', REDIS_URL) +WEBSOCKET_REDIS_CLUSTER = os.environ.get('WEBSOCKET_REDIS_CLUSTER', str(REDIS_CLUSTER)).lower() == 'true' -websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60") +websocket_redis_lock_timeout = os.environ.get('WEBSOCKET_REDIS_LOCK_TIMEOUT', '60') try: WEBSOCKET_REDIS_LOCK_TIMEOUT = int(websocket_redis_lock_timeout) except ValueError: WEBSOCKET_REDIS_LOCK_TIMEOUT = 60 -WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "") -WEBSOCKET_SENTINEL_PORT = os.environ.get("WEBSOCKET_SENTINEL_PORT", "26379") -WEBSOCKET_SERVER_LOGGING = ( - os.environ.get("WEBSOCKET_SERVER_LOGGING", "False").lower() == "true" -) +WEBSOCKET_SENTINEL_HOSTS = os.environ.get('WEBSOCKET_SENTINEL_HOSTS', '') +WEBSOCKET_SENTINEL_PORT = os.environ.get('WEBSOCKET_SENTINEL_PORT', '26379') +WEBSOCKET_SERVER_LOGGING = os.environ.get('WEBSOCKET_SERVER_LOGGING', 'False').lower() == 'true' WEBSOCKET_SERVER_ENGINEIO_LOGGING = ( os.environ.get( - "WEBSOCKET_SERVER_ENGINEIO_LOGGING", - os.environ.get("WEBSOCKET_SERVER_LOGGING", "False"), + 'WEBSOCKET_SERVER_ENGINEIO_LOGGING', + os.environ.get('WEBSOCKET_SERVER_LOGGING', 'False'), ).lower() - == "true" + == 'true' ) -WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get("WEBSOCKET_SERVER_PING_TIMEOUT", "20") +WEBSOCKET_SERVER_PING_TIMEOUT = os.environ.get('WEBSOCKET_SERVER_PING_TIMEOUT', '20') try: WEBSOCKET_SERVER_PING_TIMEOUT = int(WEBSOCKET_SERVER_PING_TIMEOUT) except ValueError: WEBSOCKET_SERVER_PING_TIMEOUT = 20 -WEBSOCKET_SERVER_PING_INTERVAL = os.environ.get("WEBSOCKET_SERVER_PING_INTERVAL", "25") +WEBSOCKET_SERVER_PING_INTERVAL = os.environ.get('WEBSOCKET_SERVER_PING_INTERVAL', '25') try: WEBSOCKET_SERVER_PING_INTERVAL = int(WEBSOCKET_SERVER_PING_INTERVAL) except ValueError: WEBSOCKET_SERVER_PING_INTERVAL = 25 -WEBSOCKET_EVENT_CALLER_TIMEOUT = os.environ.get("WEBSOCKET_EVENT_CALLER_TIMEOUT", "") +WEBSOCKET_EVENT_CALLER_TIMEOUT = os.environ.get('WEBSOCKET_EVENT_CALLER_TIMEOUT', '') -if WEBSOCKET_EVENT_CALLER_TIMEOUT == "": +if WEBSOCKET_EVENT_CALLER_TIMEOUT == '': WEBSOCKET_EVENT_CALLER_TIMEOUT = None else: try: @@ -801,11 +716,11 @@ def parse_section(section): WEBSOCKET_EVENT_CALLER_TIMEOUT = 300 -REQUESTS_VERIFY = os.environ.get("REQUESTS_VERIFY", "True").lower() == "true" +REQUESTS_VERIFY = os.environ.get('REQUESTS_VERIFY', 'True').lower() == 'true' -AIOHTTP_CLIENT_TIMEOUT = os.environ.get("AIOHTTP_CLIENT_TIMEOUT", "") +AIOHTTP_CLIENT_TIMEOUT = os.environ.get('AIOHTTP_CLIENT_TIMEOUT', '') -if AIOHTTP_CLIENT_TIMEOUT == "": +if AIOHTTP_CLIENT_TIMEOUT == '': AIOHTTP_CLIENT_TIMEOUT = None else: try: @@ -814,16 +729,14 @@ def parse_section(section): AIOHTTP_CLIENT_TIMEOUT = 300 -AIOHTTP_CLIENT_SESSION_SSL = ( - os.environ.get("AIOHTTP_CLIENT_SESSION_SSL", "True").lower() == "true" -) +AIOHTTP_CLIENT_SESSION_SSL = os.environ.get('AIOHTTP_CLIENT_SESSION_SSL', 'True').lower() == 'true' AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get( - "AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST", - os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"), + 'AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST', + os.environ.get('AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST', '10'), ) -if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == "": +if AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST == '': AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = None else: try: @@ -832,33 +745,37 @@ def parse_section(section): AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = 10 -AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = os.environ.get( - "AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA", "10" -) +AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = os.environ.get('AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA', '10') -if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA == "": +if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA == '': AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = None else: try: - AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = int( - AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA - ) + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = int(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA) except Exception: AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10 AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = ( - os.environ.get("AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL", "True").lower() == "true" + os.environ.get('AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL', 'True').lower() == 'true' ) -AIOHTTP_CLIENT_READ_BUFFER_SIZE = int( - os.environ.get("AIOHTTP_CLIENT_READ_BUFFER_SIZE", 2**16) -) +AIOHTTP_CLIENT_READ_BUFFER_SIZE = int(os.environ.get('AIOHTTP_CLIENT_READ_BUFFER_SIZE', 2**16)) +AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = os.environ.get('AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER', '') + +if AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER == '': + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT +else: + try: + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = int(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) + except Exception: + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER = AIOHTTP_CLIENT_TIMEOUT -RAG_EMBEDDING_TIMEOUT = os.environ.get("RAG_EMBEDDING_TIMEOUT", "") -if RAG_EMBEDDING_TIMEOUT == "": +RAG_EMBEDDING_TIMEOUT = os.environ.get('RAG_EMBEDDING_TIMEOUT', '') + +if RAG_EMBEDDING_TIMEOUT == '': RAG_EMBEDDING_TIMEOUT = None else: try: @@ -872,42 +789,34 @@ def parse_section(section): #################################### -SENTENCE_TRANSFORMERS_BACKEND = os.environ.get("SENTENCE_TRANSFORMERS_BACKEND", "") -if SENTENCE_TRANSFORMERS_BACKEND == "": - SENTENCE_TRANSFORMERS_BACKEND = "torch" +SENTENCE_TRANSFORMERS_BACKEND = os.environ.get('SENTENCE_TRANSFORMERS_BACKEND', '') +if SENTENCE_TRANSFORMERS_BACKEND == '': + SENTENCE_TRANSFORMERS_BACKEND = 'torch' -SENTENCE_TRANSFORMERS_MODEL_KWARGS = os.environ.get( - "SENTENCE_TRANSFORMERS_MODEL_KWARGS", "" -) -if SENTENCE_TRANSFORMERS_MODEL_KWARGS == "": +SENTENCE_TRANSFORMERS_MODEL_KWARGS = os.environ.get('SENTENCE_TRANSFORMERS_MODEL_KWARGS', '') +if SENTENCE_TRANSFORMERS_MODEL_KWARGS == '': SENTENCE_TRANSFORMERS_MODEL_KWARGS = None else: try: - SENTENCE_TRANSFORMERS_MODEL_KWARGS = json.loads( - SENTENCE_TRANSFORMERS_MODEL_KWARGS - ) + SENTENCE_TRANSFORMERS_MODEL_KWARGS = json.loads(SENTENCE_TRANSFORMERS_MODEL_KWARGS) except Exception: SENTENCE_TRANSFORMERS_MODEL_KWARGS = None -SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = os.environ.get( - "SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND", "" -) -if SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND == "": - SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = "torch" +SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = os.environ.get('SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND', '') +if SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND == '': + SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = 'torch' SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = os.environ.get( - "SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS", "" + 'SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS', '' ) -if SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS == "": +if SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS == '': SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None else: try: - SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = json.loads( - SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS - ) + SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = json.loads(SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS) except Exception: SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None @@ -915,23 +824,18 @@ def parse_section(section): # When enabled (default), scores are normalized to 0-1 range for proper # relevance threshold behavior with MS MARCO models. SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = ( - os.environ.get( - "SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION", "True" - ).lower() - == "true" + os.environ.get('SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION', 'True').lower() == 'true' ) #################################### # OFFLINE_MODE #################################### -ENABLE_VERSION_UPDATE_CHECK = ( - os.environ.get("ENABLE_VERSION_UPDATE_CHECK", "true").lower() == "true" -) -OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true" +ENABLE_VERSION_UPDATE_CHECK = os.environ.get('ENABLE_VERSION_UPDATE_CHECK', 'true').lower() == 'true' +OFFLINE_MODE = os.environ.get('OFFLINE_MODE', 'false').lower() == 'true' if OFFLINE_MODE: - os.environ["HF_HUB_OFFLINE"] = "1" + os.environ['HF_HUB_OFFLINE'] = '1' ENABLE_VERSION_UPDATE_CHECK = False #################################### @@ -939,104 +843,79 @@ def parse_section(section): #################################### -ENABLE_AUDIT_STDOUT = os.getenv("ENABLE_AUDIT_STDOUT", "False").lower() == "true" -ENABLE_AUDIT_LOGS_FILE = os.getenv("ENABLE_AUDIT_LOGS_FILE", "True").lower() == "true" +ENABLE_AUDIT_STDOUT = os.getenv('ENABLE_AUDIT_STDOUT', 'False').lower() == 'true' +ENABLE_AUDIT_LOGS_FILE = os.getenv('ENABLE_AUDIT_LOGS_FILE', 'True').lower() == 'true' # Where to store log file # Defaults to the DATA_DIR/audit.log. To set AUDIT_LOGS_FILE_PATH you need to # provide the whole path, like: /app/audit.log -AUDIT_LOGS_FILE_PATH = os.getenv("AUDIT_LOGS_FILE_PATH", f"{DATA_DIR}/audit.log") +AUDIT_LOGS_FILE_PATH = os.getenv('AUDIT_LOGS_FILE_PATH', f'{DATA_DIR}/audit.log') # Maximum size of a file before rotating into a new log file -AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv("AUDIT_LOG_FILE_ROTATION_SIZE", "10MB") +AUDIT_LOG_FILE_ROTATION_SIZE = os.getenv('AUDIT_LOG_FILE_ROTATION_SIZE', '10MB') # Comma separated list of logger names to use for audit logging # Default is "uvicorn.access" which is the access log for Uvicorn # You can add more logger names to this list if you want to capture more logs -AUDIT_UVICORN_LOGGER_NAMES = os.getenv( - "AUDIT_UVICORN_LOGGER_NAMES", "uvicorn.access" -).split(",") +AUDIT_UVICORN_LOGGER_NAMES = os.getenv('AUDIT_UVICORN_LOGGER_NAMES', 'uvicorn.access').split(',') # METADATA | REQUEST | REQUEST_RESPONSE -AUDIT_LOG_LEVEL = os.getenv("AUDIT_LOG_LEVEL", "NONE").upper() +AUDIT_LOG_LEVEL = os.getenv('AUDIT_LOG_LEVEL', 'NONE').upper() try: - MAX_BODY_LOG_SIZE = int(os.environ.get("MAX_BODY_LOG_SIZE") or 2048) + MAX_BODY_LOG_SIZE = int(os.environ.get('MAX_BODY_LOG_SIZE') or 2048) 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 = 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.lstrip('/') for path in AUDIT_EXCLUDED_PATHS] + +# 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] #################################### # OPENTELEMETRY #################################### -ENABLE_OTEL = os.environ.get("ENABLE_OTEL", "False").lower() == "true" -ENABLE_OTEL_TRACES = os.environ.get("ENABLE_OTEL_TRACES", "False").lower() == "true" -ENABLE_OTEL_METRICS = os.environ.get("ENABLE_OTEL_METRICS", "False").lower() == "true" -ENABLE_OTEL_LOGS = os.environ.get("ENABLE_OTEL_LOGS", "False").lower() == "true" +ENABLE_OTEL = os.environ.get('ENABLE_OTEL', 'False').lower() == 'true' +ENABLE_OTEL_TRACES = os.environ.get('ENABLE_OTEL_TRACES', 'False').lower() == 'true' +ENABLE_OTEL_METRICS = os.environ.get('ENABLE_OTEL_METRICS', 'False').lower() == 'true' +ENABLE_OTEL_LOGS = os.environ.get('ENABLE_OTEL_LOGS', 'False').lower() == 'true' -OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get( - "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317" -) -OTEL_METRICS_EXPORTER_OTLP_ENDPOINT = os.environ.get( - "OTEL_METRICS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT -) -OTEL_LOGS_EXPORTER_OTLP_ENDPOINT = os.environ.get( - "OTEL_LOGS_EXPORTER_OTLP_ENDPOINT", OTEL_EXPORTER_OTLP_ENDPOINT -) -OTEL_EXPORTER_OTLP_INSECURE = ( - os.environ.get("OTEL_EXPORTER_OTLP_INSECURE", "False").lower() == "true" -) +OTEL_EXPORTER_OTLP_ENDPOINT = os.environ.get('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4317') +OTEL_METRICS_EXPORTER_OTLP_ENDPOINT = os.environ.get('OTEL_METRICS_EXPORTER_OTLP_ENDPOINT', OTEL_EXPORTER_OTLP_ENDPOINT) +OTEL_LOGS_EXPORTER_OTLP_ENDPOINT = os.environ.get('OTEL_LOGS_EXPORTER_OTLP_ENDPOINT', OTEL_EXPORTER_OTLP_ENDPOINT) +OTEL_EXPORTER_OTLP_INSECURE = os.environ.get('OTEL_EXPORTER_OTLP_INSECURE', 'False').lower() == 'true' OTEL_METRICS_EXPORTER_OTLP_INSECURE = ( - os.environ.get( - "OTEL_METRICS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE) - ).lower() - == "true" + os.environ.get('OTEL_METRICS_EXPORTER_OTLP_INSECURE', str(OTEL_EXPORTER_OTLP_INSECURE)).lower() == 'true' ) OTEL_LOGS_EXPORTER_OTLP_INSECURE = ( - os.environ.get( - "OTEL_LOGS_EXPORTER_OTLP_INSECURE", str(OTEL_EXPORTER_OTLP_INSECURE) - ).lower() - == "true" -) -OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "open-webui") -OTEL_RESOURCE_ATTRIBUTES = os.environ.get( - "OTEL_RESOURCE_ATTRIBUTES", "" -) # e.g. key1=val1,key2=val2 -OTEL_TRACES_SAMPLER = os.environ.get( - "OTEL_TRACES_SAMPLER", "parentbased_always_on" -).lower() -OTEL_BASIC_AUTH_USERNAME = os.environ.get("OTEL_BASIC_AUTH_USERNAME", "") -OTEL_BASIC_AUTH_PASSWORD = os.environ.get("OTEL_BASIC_AUTH_PASSWORD", "") - -OTEL_METRICS_BASIC_AUTH_USERNAME = os.environ.get( - "OTEL_METRICS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME -) -OTEL_METRICS_BASIC_AUTH_PASSWORD = os.environ.get( - "OTEL_METRICS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD -) -OTEL_LOGS_BASIC_AUTH_USERNAME = os.environ.get( - "OTEL_LOGS_BASIC_AUTH_USERNAME", OTEL_BASIC_AUTH_USERNAME -) -OTEL_LOGS_BASIC_AUTH_PASSWORD = os.environ.get( - "OTEL_LOGS_BASIC_AUTH_PASSWORD", OTEL_BASIC_AUTH_PASSWORD + os.environ.get('OTEL_LOGS_EXPORTER_OTLP_INSECURE', str(OTEL_EXPORTER_OTLP_INSECURE)).lower() == 'true' ) +OTEL_SERVICE_NAME = os.environ.get('OTEL_SERVICE_NAME', 'open-webui') +OTEL_RESOURCE_ATTRIBUTES = os.environ.get('OTEL_RESOURCE_ATTRIBUTES', '') # e.g. key1=val1,key2=val2 +OTEL_TRACES_SAMPLER = os.environ.get('OTEL_TRACES_SAMPLER', 'parentbased_always_on').lower() +OTEL_BASIC_AUTH_USERNAME = os.environ.get('OTEL_BASIC_AUTH_USERNAME', '') +OTEL_BASIC_AUTH_PASSWORD = os.environ.get('OTEL_BASIC_AUTH_PASSWORD', '') +OTEL_METRICS_EXPORT_INTERVAL_MILLIS = int(os.environ.get('OTEL_METRICS_EXPORT_INTERVAL_MILLIS', '10000')) -OTEL_OTLP_SPAN_EXPORTER = os.environ.get( - "OTEL_OTLP_SPAN_EXPORTER", "grpc" -).lower() # grpc or http +OTEL_METRICS_BASIC_AUTH_USERNAME = os.environ.get('OTEL_METRICS_BASIC_AUTH_USERNAME', OTEL_BASIC_AUTH_USERNAME) +OTEL_METRICS_BASIC_AUTH_PASSWORD = os.environ.get('OTEL_METRICS_BASIC_AUTH_PASSWORD', OTEL_BASIC_AUTH_PASSWORD) +OTEL_LOGS_BASIC_AUTH_USERNAME = os.environ.get('OTEL_LOGS_BASIC_AUTH_USERNAME', OTEL_BASIC_AUTH_USERNAME) +OTEL_LOGS_BASIC_AUTH_PASSWORD = os.environ.get('OTEL_LOGS_BASIC_AUTH_PASSWORD', OTEL_BASIC_AUTH_PASSWORD) + +OTEL_OTLP_SPAN_EXPORTER = os.environ.get('OTEL_OTLP_SPAN_EXPORTER', 'grpc').lower() # grpc or http OTEL_METRICS_OTLP_SPAN_EXPORTER = os.environ.get( - "OTEL_METRICS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER + 'OTEL_METRICS_OTLP_SPAN_EXPORTER', OTEL_OTLP_SPAN_EXPORTER ).lower() # grpc or http OTEL_LOGS_OTLP_SPAN_EXPORTER = os.environ.get( - "OTEL_LOGS_OTLP_SPAN_EXPORTER", OTEL_OTLP_SPAN_EXPORTER + 'OTEL_LOGS_OTLP_SPAN_EXPORTER', OTEL_OTLP_SPAN_EXPORTER ).lower() # grpc or http #################################### @@ -1044,19 +923,18 @@ def parse_section(section): #################################### ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = ( - os.environ.get("ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS", "True").lower() - == "true" + os.environ.get('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS', 'True').lower() == 'true' ) -PIP_OPTIONS = os.getenv("PIP_OPTIONS", "").split() -PIP_PACKAGE_INDEX_OPTIONS = os.getenv("PIP_PACKAGE_INDEX_OPTIONS", "").split() +PIP_OPTIONS = os.getenv('PIP_OPTIONS', '').split() +PIP_PACKAGE_INDEX_OPTIONS = os.getenv('PIP_PACKAGE_INDEX_OPTIONS', '').split() #################################### # PROGRESSIVE WEB APP OPTIONS #################################### -EXTERNAL_PWA_MANIFEST_URL = os.environ.get("EXTERNAL_PWA_MANIFEST_URL") +EXTERNAL_PWA_MANIFEST_URL = os.environ.get('EXTERNAL_PWA_MANIFEST_URL') #################################### # GROUP DEFAULTS @@ -1064,9 +942,5 @@ def parse_section(section): # Controls the default "Who can share to this group" setting for new groups. # Env var values: "true" (anyone), "false" (no one), "members" (only group members). -_default_group_share = ( - os.environ.get("DEFAULT_GROUP_SHARE_PERMISSION", "members").strip().lower() -) -DEFAULT_GROUP_SHARE_PERMISSION = ( - "members" if _default_group_share == "members" else _default_group_share == "true" -) +_default_group_share = os.environ.get('DEFAULT_GROUP_SHARE_PERMISSION', 'members').strip().lower() +DEFAULT_GROUP_SHARE_PERMISSION = 'members' if _default_group_share == 'members' else _default_group_share == 'true' diff --git a/backend/open_webui/functions.py b/backend/open_webui/functions.py index 8f1357681f..0a37360f10 100644 --- a/backend/open_webui/functions.py +++ b/backend/open_webui/functions.py @@ -46,17 +46,15 @@ def get_function_module_by_id(request: Request, pipe_id: str): function_module, _, _ = get_function_module_from_cache(request, pipe_id) - if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): + if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'): Valves = function_module.Valves valves = Functions.get_function_valves_by_id(pipe_id) if valves: try: - function_module.valves = Valves( - **{k: v for k, v in valves.items() if v is not None} - ) + function_module.valves = Valves(**{k: v for k, v in valves.items() if v is not None}) except Exception as e: - log.exception(f"Error loading valves for function {pipe_id}: {e}") + log.exception(f'Error loading valves for function {pipe_id}: {e}') raise e else: function_module.valves = Valves() @@ -65,7 +63,7 @@ def get_function_module_by_id(request: Request, pipe_id: str): async def get_function_models(request): - pipes = Functions.get_functions_by_type("pipe", active_only=True) + pipes = Functions.get_functions_by_type('pipe', active_only=True) pipe_models = [] for pipe in pipes: @@ -73,11 +71,11 @@ async def get_function_models(request): function_module = get_function_module_by_id(request, pipe.id) has_user_valves = False - if hasattr(function_module, "UserValves"): + if hasattr(function_module, 'UserValves'): has_user_valves = True # Check if function is a manifold - if hasattr(function_module, "pipes"): + if hasattr(function_module, 'pipes'): sub_pipes = [] # Handle pipes being a list, sync function, or async function @@ -93,32 +91,30 @@ async def get_function_models(request): log.exception(e) sub_pipes = [] - log.debug( - f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}" - ) + log.debug(f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}") for p in sub_pipes: sub_pipe_id = f'{pipe.id}.{p["id"]}' - sub_pipe_name = p["name"] + sub_pipe_name = p['name'] - if hasattr(function_module, "name"): - sub_pipe_name = f"{function_module.name}{sub_pipe_name}" + if hasattr(function_module, 'name'): + sub_pipe_name = f'{function_module.name}{sub_pipe_name}' - pipe_flag = {"type": pipe.type} + pipe_flag = {'type': pipe.type} pipe_models.append( { - "id": sub_pipe_id, - "name": sub_pipe_name, - "object": "model", - "created": pipe.created_at, - "owned_by": "openai", - "pipe": pipe_flag, - "has_user_valves": has_user_valves, + 'id': sub_pipe_id, + 'name': sub_pipe_name, + 'object': 'model', + 'created': pipe.created_at, + 'owned_by': 'openai', + 'pipe': pipe_flag, + 'has_user_valves': has_user_valves, } ) else: - pipe_flag = {"type": "pipe"} + pipe_flag = {'type': 'pipe'} log.debug( f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}" @@ -126,13 +122,13 @@ async def get_function_models(request): pipe_models.append( { - "id": pipe.id, - "name": pipe.name, - "object": "model", - "created": pipe.created_at, - "owned_by": "openai", - "pipe": pipe_flag, - "has_user_valves": has_user_valves, + 'id': pipe.id, + 'name': pipe.name, + 'object': 'model', + 'created': pipe.created_at, + 'owned_by': 'openai', + 'pipe': pipe_flag, + 'has_user_valves': has_user_valves, } ) except Exception as e: @@ -142,9 +138,7 @@ 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 = {}): async def execute_pipe(pipe, params): if inspect.iscoroutinefunction(pipe): return await pipe(**params) @@ -155,32 +149,32 @@ async def get_message_content(res: str | Generator | AsyncGenerator) -> str: if isinstance(res, str): return res if isinstance(res, Generator): - return "".join(map(str, res)) + return ''.join(map(str, res)) if isinstance(res, AsyncGenerator): - return "".join([str(stream) async for stream in res]) + return ''.join([str(stream) async for stream in res]) def process_line(form_data: dict, line): if isinstance(line, BaseModel): line = line.model_dump_json() - line = f"data: {line}" + line = f'data: {line}' if isinstance(line, dict): - line = f"data: {json.dumps(line)}" + line = f'data: {json.dumps(line)}' try: - line = line.decode("utf-8") + line = line.decode('utf-8') except Exception: pass - if line.startswith("data:"): - return f"{line}\n\n" + if line.startswith('data:'): + return f'{line}\n\n' else: - line = openai_chat_chunk_message_template(form_data["model"], line) - return f"data: {json.dumps(line)}\n\n" + line = openai_chat_chunk_message_template(form_data['model'], line) + return f'data: {json.dumps(line)}\n\n' def get_pipe_id(form_data: dict) -> str: - pipe_id = form_data["model"] - if "." in pipe_id: - pipe_id, _ = pipe_id.split(".", 1) + pipe_id = form_data['model'] + if '.' in pipe_id: + pipe_id, _ = pipe_id.split('.', 1) return pipe_id def get_function_params(function_module, form_data, user, extra_params=None): @@ -191,27 +185,25 @@ def get_function_params(function_module, form_data, user, extra_params=None): # Get the signature of the function sig = inspect.signature(function_module.pipe) - params = {"body": form_data} | { - k: v for k, v in extra_params.items() if k in sig.parameters - } + params = {'body': form_data} | {k: v for k, v in extra_params.items() if k in sig.parameters} - if "__user__" in params and hasattr(function_module, "UserValves"): + if '__user__' in params and hasattr(function_module, 'UserValves'): user_valves = Functions.get_user_valves_by_id_and_user_id(pipe_id, user.id) try: - params["__user__"]["valves"] = function_module.UserValves(**user_valves) + params['__user__']['valves'] = function_module.UserValves(**user_valves) except Exception as e: log.exception(e) - params["__user__"]["valves"] = function_module.UserValves() + params['__user__']['valves'] = function_module.UserValves() return params - model_id = form_data.get("model") + model_id = form_data.get('model') model_info = Models.get_model_by_id(model_id) - metadata = form_data.pop("metadata", {}) + metadata = form_data.pop('metadata', {}) - files = metadata.get("files", []) - tool_ids = metadata.get("tool_ids", []) + files = metadata.get('files', []) + tool_ids = metadata.get('tool_ids', []) # Check if tool_ids is None if tool_ids is None: tool_ids = [] @@ -222,56 +214,56 @@ def get_function_params(function_module, form_data, user, extra_params=None): __task_body__ = None if metadata: - if all(k in metadata for k in ("session_id", "chat_id", "message_id")): + if all(k in metadata for k in ('session_id', 'chat_id', 'message_id')): __event_emitter__ = get_event_emitter(metadata) __event_call__ = get_event_call(metadata) - __task__ = metadata.get("task", None) - __task_body__ = metadata.get("task_body", None) + __task__ = metadata.get('task', None) + __task_body__ = metadata.get('task_body', None) oauth_token = None try: - if request.cookies.get("oauth_session_id", None): + if request.cookies.get('oauth_session_id', None): oauth_token = await request.app.state.oauth_manager.get_oauth_token( user.id, - request.cookies.get("oauth_session_id", None), + request.cookies.get('oauth_session_id', None), ) except Exception as e: - log.error(f"Error getting OAuth token: {e}") + log.error(f'Error getting OAuth token: {e}') extra_params = { - "__event_emitter__": __event_emitter__, - "__event_call__": __event_call__, - "__chat_id__": metadata.get("chat_id", None), - "__session_id__": metadata.get("session_id", None), - "__message_id__": metadata.get("message_id", None), - "__task__": __task__, - "__task_body__": __task_body__, - "__files__": files, - "__user__": user.model_dump() if isinstance(user, UserModel) else {}, - "__metadata__": metadata, - "__oauth_token__": oauth_token, - "__request__": request, + '__event_emitter__': __event_emitter__, + '__event_call__': __event_call__, + '__chat_id__': metadata.get('chat_id', None), + '__session_id__': metadata.get('session_id', None), + '__message_id__': metadata.get('message_id', None), + '__task__': __task__, + '__task_body__': __task_body__, + '__files__': files, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__oauth_token__': oauth_token, + '__request__': request, } - extra_params["__tools__"] = await get_tools( + extra_params['__tools__'] = await get_tools( request, tool_ids, user, { **extra_params, - "__model__": models.get(form_data["model"], None), - "__messages__": form_data["messages"], - "__files__": files, + '__model__': models.get(form_data['model'], None), + '__messages__': form_data['messages'], + '__files__': files, }, ) if model_info: if model_info.base_model_id: - form_data["model"] = model_info.base_model_id + form_data['model'] = model_info.base_model_id params = model_info.params.model_dump() if params: - system = params.pop("system", None) + system = params.pop('system', None) form_data = apply_model_params_to_body_openai(params, form_data) form_data = apply_system_prompt_to_body(system, form_data, metadata, user) @@ -281,7 +273,7 @@ def get_function_params(function_module, form_data, user, extra_params=None): pipe = function_module.pipe params = get_function_params(function_module, form_data, user, extra_params) - if form_data.get("stream", False): + if form_data.get('stream', False): async def stream_content(): try: @@ -295,7 +287,6 @@ async def stream_content(): body=form_data, is_stream=True, ) as credit_deduct: - async for data in res.body_iterator: credit_deduct.run(data) yield data @@ -313,12 +304,12 @@ async def stream_content(): ) as credit_deduct: credit_deduct.run(res) res = credit_deduct.add_usage_to_resp(res) - yield f"data: {json.dumps(res)}\n\n" + yield f'data: {json.dumps(res)}\n\n' return except Exception as e: - log.error(f"Error: {e}") - yield f"data: {json.dumps({'error': {'detail': str(e)}})}\n\n" + log.error(f'Error: {e}') + yield f'data: {json.dumps({"error": {"detail": str(e)}})}\n\n' return with CreditDeduct( @@ -327,13 +318,10 @@ async def stream_content(): body=form_data, is_stream=True, ) as credit_deduct: - if isinstance(res, str): - message = openai_chat_chunk_message_template( - form_data["model"], res - ) + message = openai_chat_chunk_message_template(form_data['model'], res) credit_deduct.run(message) - yield f"data: {json.dumps(message)}\n\n" + yield f'data: {json.dumps(message)}\n\n' if isinstance(res, Iterator): for line in res: @@ -348,23 +336,21 @@ async def stream_content(): yield line if isinstance(res, str) or isinstance(res, Generator): - finish_message = openai_chat_chunk_message_template( - form_data["model"], "" - ) - finish_message["choices"][0]["finish_reason"] = "stop" - yield f"data: {json.dumps(finish_message)}\n\n" - yield "data: [DONE]" + finish_message = openai_chat_chunk_message_template(form_data['model'], '') + finish_message['choices'][0]['finish_reason'] = 'stop' + yield f'data: {json.dumps(finish_message)}\n\n' + yield 'data: [DONE]' yield credit_deduct.usage_message - return StreamingResponse(stream_content(), media_type="text/event-stream") + return StreamingResponse(stream_content(), media_type='text/event-stream') else: try: res = await execute_pipe(pipe, params) except Exception as e: - log.error(f"Error: {e}") - return {"error": {"detail": str(e)}} + log.error(f'Error: {e}') + return {'error': {'detail': str(e)}} async def to_stream(response): with CreditDeduct( @@ -373,7 +359,6 @@ async def to_stream(response): body=form_data, is_stream=True, ) as credit_deduct: - async for data in response.body_iterator: credit_deduct.run(data) yield data @@ -381,7 +366,7 @@ async def to_stream(response): yield credit_deduct.usage_message if isinstance(res, StreamingResponse): - return StreamingResponse(to_stream(res), media_type="text/event-stream") + return StreamingResponse(to_stream(res), media_type='text/event-stream') with CreditDeduct( user=user, @@ -399,6 +384,6 @@ async def to_stream(response): return credit_deduct.add_usage_to_resp(res) message = await get_message_content(res) - res = openai_chat_completion_message_template(form_data["model"], message) + res = openai_chat_completion_message_template(form_data['model'], message) credit_deduct.run(res) return credit_deduct.add_usage_to_resp(res) diff --git a/backend/open_webui/internal/db.py b/backend/open_webui/internal/db.py index afc2e76621..b0545255a6 100644 --- a/backend/open_webui/internal/db.py +++ b/backend/open_webui/internal/db.py @@ -56,17 +56,15 @@ def handle_peewee_migration(DATABASE_URL): # db = None try: # Replace the postgresql:// with postgres:// to handle the peewee migration - db = register_connection(DATABASE_URL.replace("postgresql://", "postgres://")) - migrate_dir = OPEN_WEBUI_DIR / "internal" / "migrations" + db = register_connection(DATABASE_URL.replace('postgresql://', 'postgres://')) + migrate_dir = OPEN_WEBUI_DIR / 'internal' / 'migrations' router = Router(db, logger=log, migrate_dir=migrate_dir) router.run() db.close() except Exception as e: - log.error(f"Failed to initialize the database connection: {e}") - log.warning( - "Hint: If your database password contains special characters, you may need to URL-encode it." - ) + log.error(f'Failed to initialize the database connection: {e}') + log.warning('Hint: If your database password contains special characters, you may need to URL-encode it.') raise finally: # Properly closing the database connection @@ -74,7 +72,7 @@ def handle_peewee_migration(DATABASE_URL): db.close() # Assert if db connection has been closed - assert db.is_closed(), "Database connection is still open." + assert db.is_closed(), 'Database connection is still open.' if ENABLE_DB_MIGRATIONS: @@ -84,15 +82,13 @@ def handle_peewee_migration(DATABASE_URL): SQLALCHEMY_DATABASE_URL = DATABASE_URL # Handle SQLCipher URLs -if SQLALCHEMY_DATABASE_URL.startswith("sqlite+sqlcipher://"): - database_password = os.environ.get("DATABASE_PASSWORD") - if not database_password or database_password.strip() == "": - raise ValueError( - "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs" - ) +if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'): + database_password = os.environ.get('DATABASE_PASSWORD') + if not database_password or database_password.strip() == '': + raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs') # Extract database path from SQLCipher URL - db_path = SQLALCHEMY_DATABASE_URL.replace("sqlite+sqlcipher://", "") + db_path = SQLALCHEMY_DATABASE_URL.replace('sqlite+sqlcipher://', '') # Create a custom creator function that uses sqlcipher3 def create_sqlcipher_connection(): @@ -109,7 +105,7 @@ def create_sqlcipher_connection(): # or QueuePool if DATABASE_POOL_SIZE is explicitly configured. if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0: engine = create_engine( - "sqlite://", + 'sqlite://', creator=create_sqlcipher_connection, pool_size=DATABASE_POOL_SIZE, max_overflow=DATABASE_POOL_MAX_OVERFLOW, @@ -121,28 +117,26 @@ def create_sqlcipher_connection(): ) else: engine = create_engine( - "sqlite://", + 'sqlite://', creator=create_sqlcipher_connection, poolclass=NullPool, echo=False, ) - log.info("Connected to encrypted SQLite database using SQLCipher") + log.info('Connected to encrypted SQLite database using SQLCipher') -elif "sqlite" in SQLALCHEMY_DATABASE_URL: - engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} - ) +elif 'sqlite' in SQLALCHEMY_DATABASE_URL: + engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False}) def on_connect(dbapi_connection, connection_record): cursor = dbapi_connection.cursor() if DATABASE_ENABLE_SQLITE_WAL: - cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute('PRAGMA journal_mode=WAL') else: - cursor.execute("PRAGMA journal_mode=DELETE") + cursor.execute('PRAGMA journal_mode=DELETE') cursor.close() - event.listen(engine, "connect", on_connect) + event.listen(engine, 'connect', on_connect) else: if isinstance(DATABASE_POOL_SIZE, int): if DATABASE_POOL_SIZE > 0: @@ -156,16 +150,12 @@ def on_connect(dbapi_connection, connection_record): poolclass=QueuePool, ) else: - engine = create_engine( - SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool - ) + engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool) else: engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) -SessionLocal = sessionmaker( - autocommit=False, autoflush=False, bind=engine, expire_on_commit=False -) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) metadata_obj = MetaData(schema=DATABASE_SCHEMA) Base = declarative_base(metadata=metadata_obj) ScopedSession = scoped_session(SessionLocal) diff --git a/backend/open_webui/internal/migrations/001_initial_schema.py b/backend/open_webui/internal/migrations/001_initial_schema.py index 0df2249b21..4268201ae7 100644 --- a/backend/open_webui/internal/migrations/001_initial_schema.py +++ b/backend/open_webui/internal/migrations/001_initial_schema.py @@ -56,7 +56,7 @@ class Auth(pw.Model): active = pw.BooleanField() class Meta: - table_name = "auth" + table_name = 'auth' @migrator.create_model class Chat(pw.Model): @@ -67,7 +67,7 @@ class Chat(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "chat" + table_name = 'chat' @migrator.create_model class ChatIdTag(pw.Model): @@ -78,7 +78,7 @@ class ChatIdTag(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "chatidtag" + table_name = 'chatidtag' @migrator.create_model class Document(pw.Model): @@ -92,7 +92,7 @@ class Document(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "document" + table_name = 'document' @migrator.create_model class Modelfile(pw.Model): @@ -103,7 +103,7 @@ class Modelfile(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "modelfile" + table_name = 'modelfile' @migrator.create_model class Prompt(pw.Model): @@ -115,7 +115,7 @@ class Prompt(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "prompt" + table_name = 'prompt' @migrator.create_model class Tag(pw.Model): @@ -125,7 +125,7 @@ class Tag(pw.Model): data = pw.TextField(null=True) class Meta: - table_name = "tag" + table_name = 'tag' @migrator.create_model class User(pw.Model): @@ -137,7 +137,7 @@ class User(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "user" + table_name = 'user' def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False): @@ -149,7 +149,7 @@ class Auth(pw.Model): active = pw.BooleanField() class Meta: - table_name = "auth" + table_name = 'auth' @migrator.create_model class Chat(pw.Model): @@ -160,7 +160,7 @@ class Chat(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "chat" + table_name = 'chat' @migrator.create_model class ChatIdTag(pw.Model): @@ -171,7 +171,7 @@ class ChatIdTag(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "chatidtag" + table_name = 'chatidtag' @migrator.create_model class Document(pw.Model): @@ -185,7 +185,7 @@ class Document(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "document" + table_name = 'document' @migrator.create_model class Modelfile(pw.Model): @@ -196,7 +196,7 @@ class Modelfile(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "modelfile" + table_name = 'modelfile' @migrator.create_model class Prompt(pw.Model): @@ -208,7 +208,7 @@ class Prompt(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "prompt" + table_name = 'prompt' @migrator.create_model class Tag(pw.Model): @@ -218,7 +218,7 @@ class Tag(pw.Model): data = pw.TextField(null=True) class Meta: - table_name = "tag" + table_name = 'tag' @migrator.create_model class User(pw.Model): @@ -230,24 +230,24 @@ class User(pw.Model): timestamp = pw.BigIntegerField() class Meta: - table_name = "user" + table_name = 'user' def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_model("user") + migrator.remove_model('user') - migrator.remove_model("tag") + migrator.remove_model('tag') - migrator.remove_model("prompt") + migrator.remove_model('prompt') - migrator.remove_model("modelfile") + migrator.remove_model('modelfile') - migrator.remove_model("document") + migrator.remove_model('document') - migrator.remove_model("chatidtag") + migrator.remove_model('chatidtag') - migrator.remove_model("chat") + migrator.remove_model('chat') - migrator.remove_model("auth") + migrator.remove_model('auth') diff --git a/backend/open_webui/internal/migrations/002_add_local_sharing.py b/backend/open_webui/internal/migrations/002_add_local_sharing.py index a01862d103..e3e557602b 100644 --- a/backend/open_webui/internal/migrations/002_add_local_sharing.py +++ b/backend/open_webui/internal/migrations/002_add_local_sharing.py @@ -36,12 +36,10 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" - migrator.add_fields( - "chat", share_id=pw.CharField(max_length=255, null=True, unique=True) - ) + migrator.add_fields('chat', share_id=pw.CharField(max_length=255, null=True, unique=True)) def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_fields("chat", "share_id") + migrator.remove_fields('chat', 'share_id') diff --git a/backend/open_webui/internal/migrations/003_add_auth_api_key.py b/backend/open_webui/internal/migrations/003_add_auth_api_key.py index 23cba26383..acb63fc728 100644 --- a/backend/open_webui/internal/migrations/003_add_auth_api_key.py +++ b/backend/open_webui/internal/migrations/003_add_auth_api_key.py @@ -36,12 +36,10 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" - migrator.add_fields( - "user", api_key=pw.CharField(max_length=255, null=True, unique=True) - ) + migrator.add_fields('user', api_key=pw.CharField(max_length=255, null=True, unique=True)) def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_fields("user", "api_key") + migrator.remove_fields('user', 'api_key') diff --git a/backend/open_webui/internal/migrations/004_add_archived.py b/backend/open_webui/internal/migrations/004_add_archived.py index 11108a3e0b..abed1727b9 100644 --- a/backend/open_webui/internal/migrations/004_add_archived.py +++ b/backend/open_webui/internal/migrations/004_add_archived.py @@ -36,10 +36,10 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" - migrator.add_fields("chat", archived=pw.BooleanField(default=False)) + migrator.add_fields('chat', archived=pw.BooleanField(default=False)) def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_fields("chat", "archived") + migrator.remove_fields('chat', 'archived') diff --git a/backend/open_webui/internal/migrations/005_add_updated_at.py b/backend/open_webui/internal/migrations/005_add_updated_at.py index f7fc69a5db..bff311e2d4 100644 --- a/backend/open_webui/internal/migrations/005_add_updated_at.py +++ b/backend/open_webui/internal/migrations/005_add_updated_at.py @@ -45,22 +45,20 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False): # Adding fields created_at and updated_at to the 'chat' table migrator.add_fields( - "chat", + 'chat', created_at=pw.DateTimeField(null=True), # Allow null for transition updated_at=pw.DateTimeField(null=True), # Allow null for transition ) # Populate the new fields from an existing 'timestamp' field - migrator.sql( - "UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL" - ) + migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL') # Now that the data has been copied, remove the original 'timestamp' field - migrator.remove_fields("chat", "timestamp") + migrator.remove_fields('chat', 'timestamp') # Update the fields to be not null now that they are populated migrator.change_fields( - "chat", + 'chat', created_at=pw.DateTimeField(null=False), updated_at=pw.DateTimeField(null=False), ) @@ -69,22 +67,20 @@ def migrate_sqlite(migrator: Migrator, database: pw.Database, *, fake=False): def migrate_external(migrator: Migrator, database: pw.Database, *, fake=False): # Adding fields created_at and updated_at to the 'chat' table migrator.add_fields( - "chat", + 'chat', created_at=pw.BigIntegerField(null=True), # Allow null for transition updated_at=pw.BigIntegerField(null=True), # Allow null for transition ) # Populate the new fields from an existing 'timestamp' field - migrator.sql( - "UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL" - ) + migrator.sql('UPDATE chat SET created_at = timestamp, updated_at = timestamp WHERE timestamp IS NOT NULL') # Now that the data has been copied, remove the original 'timestamp' field - migrator.remove_fields("chat", "timestamp") + migrator.remove_fields('chat', 'timestamp') # Update the fields to be not null now that they are populated migrator.change_fields( - "chat", + 'chat', created_at=pw.BigIntegerField(null=False), updated_at=pw.BigIntegerField(null=False), ) @@ -101,29 +97,29 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False): def rollback_sqlite(migrator: Migrator, database: pw.Database, *, fake=False): # Recreate the timestamp field initially allowing null values for safe transition - migrator.add_fields("chat", timestamp=pw.DateTimeField(null=True)) + migrator.add_fields('chat', timestamp=pw.DateTimeField(null=True)) # Copy the earliest created_at date back into the new timestamp field # This assumes created_at was originally a copy of timestamp - migrator.sql("UPDATE chat SET timestamp = created_at") + migrator.sql('UPDATE chat SET timestamp = created_at') # Remove the created_at and updated_at fields - migrator.remove_fields("chat", "created_at", "updated_at") + migrator.remove_fields('chat', 'created_at', 'updated_at') # Finally, alter the timestamp field to not allow nulls if that was the original setting - migrator.change_fields("chat", timestamp=pw.DateTimeField(null=False)) + migrator.change_fields('chat', timestamp=pw.DateTimeField(null=False)) def rollback_external(migrator: Migrator, database: pw.Database, *, fake=False): # Recreate the timestamp field initially allowing null values for safe transition - migrator.add_fields("chat", timestamp=pw.BigIntegerField(null=True)) + migrator.add_fields('chat', timestamp=pw.BigIntegerField(null=True)) # Copy the earliest created_at date back into the new timestamp field # This assumes created_at was originally a copy of timestamp - migrator.sql("UPDATE chat SET timestamp = created_at") + migrator.sql('UPDATE chat SET timestamp = created_at') # Remove the created_at and updated_at fields - migrator.remove_fields("chat", "created_at", "updated_at") + migrator.remove_fields('chat', 'created_at', 'updated_at') # Finally, alter the timestamp field to not allow nulls if that was the original setting - migrator.change_fields("chat", timestamp=pw.BigIntegerField(null=False)) + migrator.change_fields('chat', timestamp=pw.BigIntegerField(null=False)) diff --git a/backend/open_webui/internal/migrations/006_migrate_timestamps_and_charfields.py b/backend/open_webui/internal/migrations/006_migrate_timestamps_and_charfields.py index abe7016c57..86f90eb880 100644 --- a/backend/open_webui/internal/migrations/006_migrate_timestamps_and_charfields.py +++ b/backend/open_webui/internal/migrations/006_migrate_timestamps_and_charfields.py @@ -38,45 +38,45 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): # Alter the tables with timestamps migrator.change_fields( - "chatidtag", + 'chatidtag', timestamp=pw.BigIntegerField(), ) migrator.change_fields( - "document", + 'document', timestamp=pw.BigIntegerField(), ) migrator.change_fields( - "modelfile", + 'modelfile', timestamp=pw.BigIntegerField(), ) migrator.change_fields( - "prompt", + 'prompt', timestamp=pw.BigIntegerField(), ) migrator.change_fields( - "user", + 'user', timestamp=pw.BigIntegerField(), ) # Alter the tables with varchar to text where necessary migrator.change_fields( - "auth", + 'auth', password=pw.TextField(), ) migrator.change_fields( - "chat", + 'chat', title=pw.TextField(), ) migrator.change_fields( - "document", + 'document', title=pw.TextField(), filename=pw.TextField(), ) migrator.change_fields( - "prompt", + 'prompt', title=pw.TextField(), ) migrator.change_fields( - "user", + 'user', profile_image_url=pw.TextField(), ) @@ -87,43 +87,43 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False): if isinstance(database, pw.SqliteDatabase): # Alter the tables with timestamps migrator.change_fields( - "chatidtag", + 'chatidtag', timestamp=pw.DateField(), ) migrator.change_fields( - "document", + 'document', timestamp=pw.DateField(), ) migrator.change_fields( - "modelfile", + 'modelfile', timestamp=pw.DateField(), ) migrator.change_fields( - "prompt", + 'prompt', timestamp=pw.DateField(), ) migrator.change_fields( - "user", + 'user', timestamp=pw.DateField(), ) migrator.change_fields( - "auth", + 'auth', password=pw.CharField(max_length=255), ) migrator.change_fields( - "chat", + 'chat', title=pw.CharField(), ) migrator.change_fields( - "document", + 'document', title=pw.CharField(), filename=pw.CharField(), ) migrator.change_fields( - "prompt", + 'prompt', title=pw.CharField(), ) migrator.change_fields( - "user", + 'user', profile_image_url=pw.CharField(), ) diff --git a/backend/open_webui/internal/migrations/007_add_user_last_active_at.py b/backend/open_webui/internal/migrations/007_add_user_last_active_at.py index 3f89a5f59f..19a26c3515 100644 --- a/backend/open_webui/internal/migrations/007_add_user_last_active_at.py +++ b/backend/open_webui/internal/migrations/007_add_user_last_active_at.py @@ -38,7 +38,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): # Adding fields created_at and updated_at to the 'user' table migrator.add_fields( - "user", + 'user', created_at=pw.BigIntegerField(null=True), # Allow null for transition updated_at=pw.BigIntegerField(null=True), # Allow null for transition last_active_at=pw.BigIntegerField(null=True), # Allow null for transition @@ -50,11 +50,11 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): ) # Now that the data has been copied, remove the original 'timestamp' field - migrator.remove_fields("user", "timestamp") + migrator.remove_fields('user', 'timestamp') # Update the fields to be not null now that they are populated migrator.change_fields( - "user", + 'user', created_at=pw.BigIntegerField(null=False), updated_at=pw.BigIntegerField(null=False), last_active_at=pw.BigIntegerField(null=False), @@ -65,14 +65,14 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" # Recreate the timestamp field initially allowing null values for safe transition - migrator.add_fields("user", timestamp=pw.BigIntegerField(null=True)) + migrator.add_fields('user', timestamp=pw.BigIntegerField(null=True)) # Copy the earliest created_at date back into the new timestamp field # This assumes created_at was originally a copy of timestamp migrator.sql('UPDATE "user" SET timestamp = created_at') # Remove the created_at and updated_at fields - migrator.remove_fields("user", "created_at", "updated_at", "last_active_at") + migrator.remove_fields('user', 'created_at', 'updated_at', 'last_active_at') # Finally, alter the timestamp field to not allow nulls if that was the original setting - migrator.change_fields("user", timestamp=pw.BigIntegerField(null=False)) + migrator.change_fields('user', timestamp=pw.BigIntegerField(null=False)) diff --git a/backend/open_webui/internal/migrations/008_add_memory.py b/backend/open_webui/internal/migrations/008_add_memory.py index 96be907eba..f3af64fe95 100644 --- a/backend/open_webui/internal/migrations/008_add_memory.py +++ b/backend/open_webui/internal/migrations/008_add_memory.py @@ -43,10 +43,10 @@ class Memory(pw.Model): created_at = pw.BigIntegerField(null=False) class Meta: - table_name = "memory" + table_name = 'memory' def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_model("memory") + migrator.remove_model('memory') diff --git a/backend/open_webui/internal/migrations/009_add_models.py b/backend/open_webui/internal/migrations/009_add_models.py index 0a8d73bd3b..45f4a3d163 100644 --- a/backend/open_webui/internal/migrations/009_add_models.py +++ b/backend/open_webui/internal/migrations/009_add_models.py @@ -51,10 +51,10 @@ class Model(pw.Model): updated_at = pw.BigIntegerField(null=False) class Meta: - table_name = "model" + table_name = 'model' def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_model("model") + migrator.remove_model('model') diff --git a/backend/open_webui/internal/migrations/010_migrate_modelfiles_to_models.py b/backend/open_webui/internal/migrations/010_migrate_modelfiles_to_models.py index 322ddd44ec..e523d6a098 100644 --- a/backend/open_webui/internal/migrations/010_migrate_modelfiles_to_models.py +++ b/backend/open_webui/internal/migrations/010_migrate_modelfiles_to_models.py @@ -42,12 +42,12 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): # Fetch data from 'modelfile' table and insert into 'model' table migrate_modelfile_to_model(migrator, database) # Drop the 'modelfile' table - migrator.remove_model("modelfile") + migrator.remove_model('modelfile') def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database): - ModelFile = migrator.orm["modelfile"] - Model = migrator.orm["model"] + ModelFile = migrator.orm['modelfile'] + Model = migrator.orm['model'] modelfiles = ModelFile.select() @@ -57,25 +57,25 @@ def migrate_modelfile_to_model(migrator: Migrator, database: pw.Database): modelfile.modelfile = json.loads(modelfile.modelfile) meta = json.dumps( { - "description": modelfile.modelfile.get("desc"), - "profile_image_url": modelfile.modelfile.get("imageUrl"), - "ollama": {"modelfile": modelfile.modelfile.get("content")}, - "suggestion_prompts": modelfile.modelfile.get("suggestionPrompts"), - "categories": modelfile.modelfile.get("categories"), - "user": {**modelfile.modelfile.get("user", {}), "community": True}, + 'description': modelfile.modelfile.get('desc'), + 'profile_image_url': modelfile.modelfile.get('imageUrl'), + 'ollama': {'modelfile': modelfile.modelfile.get('content')}, + 'suggestion_prompts': modelfile.modelfile.get('suggestionPrompts'), + 'categories': modelfile.modelfile.get('categories'), + 'user': {**modelfile.modelfile.get('user', {}), 'community': True}, } ) - info = parse_ollama_modelfile(modelfile.modelfile.get("content")) + info = parse_ollama_modelfile(modelfile.modelfile.get('content')) # Insert the processed data into the 'model' table Model.create( - id=f"ollama-{modelfile.tag_name}", + id=f'ollama-{modelfile.tag_name}', user_id=modelfile.user_id, - base_model_id=info.get("base_model_id"), - name=modelfile.modelfile.get("title"), + base_model_id=info.get('base_model_id'), + name=modelfile.modelfile.get('title'), meta=meta, - params=json.dumps(info.get("params", {})), + params=json.dumps(info.get('params', {})), created_at=modelfile.timestamp, updated_at=modelfile.timestamp, ) @@ -86,7 +86,7 @@ def rollback(migrator: Migrator, database: pw.Database, *, fake=False): recreate_modelfile_table(migrator, database) move_data_back_to_modelfile(migrator, database) - migrator.remove_model("model") + migrator.remove_model('model') def recreate_modelfile_table(migrator: Migrator, database: pw.Database): @@ -102,8 +102,8 @@ def recreate_modelfile_table(migrator: Migrator, database: pw.Database): def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database): - Model = migrator.orm["model"] - Modelfile = migrator.orm["modelfile"] + Model = migrator.orm['model'] + Modelfile = migrator.orm['modelfile'] models = Model.select() @@ -112,13 +112,13 @@ def move_data_back_to_modelfile(migrator: Migrator, database: pw.Database): meta = json.loads(model.meta) modelfile_data = { - "title": model.name, - "desc": meta.get("description"), - "imageUrl": meta.get("profile_image_url"), - "content": meta.get("ollama", {}).get("modelfile"), - "suggestionPrompts": meta.get("suggestion_prompts"), - "categories": meta.get("categories"), - "user": {k: v for k, v in meta.get("user", {}).items() if k != "community"}, + 'title': model.name, + 'desc': meta.get('description'), + 'imageUrl': meta.get('profile_image_url'), + 'content': meta.get('ollama', {}).get('modelfile'), + 'suggestionPrompts': meta.get('suggestion_prompts'), + 'categories': meta.get('categories'), + 'user': {k: v for k, v in meta.get('user', {}).items() if k != 'community'}, } # Insert the processed data back into the 'modelfile' table diff --git a/backend/open_webui/internal/migrations/011_add_user_settings.py b/backend/open_webui/internal/migrations/011_add_user_settings.py index c3b9ab6edc..73d27392f7 100644 --- a/backend/open_webui/internal/migrations/011_add_user_settings.py +++ b/backend/open_webui/internal/migrations/011_add_user_settings.py @@ -37,11 +37,11 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" # Adding fields settings to the 'user' table - migrator.add_fields("user", settings=pw.TextField(null=True)) + migrator.add_fields('user', settings=pw.TextField(null=True)) def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" # Remove the settings field - migrator.remove_fields("user", "settings") + migrator.remove_fields('user', 'settings') diff --git a/backend/open_webui/internal/migrations/012_add_tools.py b/backend/open_webui/internal/migrations/012_add_tools.py index ac3cd8bfec..a488678c3c 100644 --- a/backend/open_webui/internal/migrations/012_add_tools.py +++ b/backend/open_webui/internal/migrations/012_add_tools.py @@ -51,10 +51,10 @@ class Tool(pw.Model): updated_at = pw.BigIntegerField(null=False) class Meta: - table_name = "tool" + table_name = 'tool' def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_model("tool") + migrator.remove_model('tool') diff --git a/backend/open_webui/internal/migrations/013_add_user_info.py b/backend/open_webui/internal/migrations/013_add_user_info.py index 6fafa951f0..db77cfff3a 100644 --- a/backend/open_webui/internal/migrations/013_add_user_info.py +++ b/backend/open_webui/internal/migrations/013_add_user_info.py @@ -37,11 +37,11 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" # Adding fields info to the 'user' table - migrator.add_fields("user", info=pw.TextField(null=True)) + migrator.add_fields('user', info=pw.TextField(null=True)) def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" # Remove the settings field - migrator.remove_fields("user", "info") + migrator.remove_fields('user', 'info') diff --git a/backend/open_webui/internal/migrations/014_add_files.py b/backend/open_webui/internal/migrations/014_add_files.py index 655b00d238..9c01ac08c3 100644 --- a/backend/open_webui/internal/migrations/014_add_files.py +++ b/backend/open_webui/internal/migrations/014_add_files.py @@ -45,10 +45,10 @@ class File(pw.Model): created_at = pw.BigIntegerField(null=False) class Meta: - table_name = "file" + table_name = 'file' def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_model("file") + migrator.remove_model('file') diff --git a/backend/open_webui/internal/migrations/015_add_functions.py b/backend/open_webui/internal/migrations/015_add_functions.py index 84d2843839..488e546ab1 100644 --- a/backend/open_webui/internal/migrations/015_add_functions.py +++ b/backend/open_webui/internal/migrations/015_add_functions.py @@ -51,10 +51,10 @@ class Function(pw.Model): updated_at = pw.BigIntegerField(null=False) class Meta: - table_name = "function" + table_name = 'function' def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_model("function") + migrator.remove_model('function') diff --git a/backend/open_webui/internal/migrations/016_add_valves_and_is_active.py b/backend/open_webui/internal/migrations/016_add_valves_and_is_active.py index fadf964e46..57a2dfbd5b 100644 --- a/backend/open_webui/internal/migrations/016_add_valves_and_is_active.py +++ b/backend/open_webui/internal/migrations/016_add_valves_and_is_active.py @@ -36,14 +36,14 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" - migrator.add_fields("tool", valves=pw.TextField(null=True)) - migrator.add_fields("function", valves=pw.TextField(null=True)) - migrator.add_fields("function", is_active=pw.BooleanField(default=False)) + migrator.add_fields('tool', valves=pw.TextField(null=True)) + migrator.add_fields('function', valves=pw.TextField(null=True)) + migrator.add_fields('function', is_active=pw.BooleanField(default=False)) def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_fields("tool", "valves") - migrator.remove_fields("function", "valves") - migrator.remove_fields("function", "is_active") + migrator.remove_fields('tool', 'valves') + migrator.remove_fields('function', 'valves') + migrator.remove_fields('function', 'is_active') diff --git a/backend/open_webui/internal/migrations/017_add_user_oauth_sub.py b/backend/open_webui/internal/migrations/017_add_user_oauth_sub.py index 67a36b4889..f998c742d1 100644 --- a/backend/open_webui/internal/migrations/017_add_user_oauth_sub.py +++ b/backend/open_webui/internal/migrations/017_add_user_oauth_sub.py @@ -33,7 +33,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" migrator.add_fields( - "user", + 'user', oauth_sub=pw.TextField(null=True, unique=True), ) @@ -41,4 +41,4 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_fields("user", "oauth_sub") + migrator.remove_fields('user', 'oauth_sub') diff --git a/backend/open_webui/internal/migrations/018_add_function_is_global.py b/backend/open_webui/internal/migrations/018_add_function_is_global.py index 1e932ed710..7f7cd4f725 100644 --- a/backend/open_webui/internal/migrations/018_add_function_is_global.py +++ b/backend/open_webui/internal/migrations/018_add_function_is_global.py @@ -37,7 +37,7 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): """Write your migrations here.""" migrator.add_fields( - "function", + 'function', is_global=pw.BooleanField(default=False), ) @@ -45,4 +45,4 @@ def migrate(migrator: Migrator, database: pw.Database, *, fake=False): def rollback(migrator: Migrator, database: pw.Database, *, fake=False): """Write your rollback migrations here.""" - migrator.remove_fields("function", "is_global") + migrator.remove_fields('function', 'is_global') diff --git a/backend/open_webui/internal/wrappers.py b/backend/open_webui/internal/wrappers.py index 80b1aab8ff..3d54d02e3a 100644 --- a/backend/open_webui/internal/wrappers.py +++ b/backend/open_webui/internal/wrappers.py @@ -10,13 +10,13 @@ log = logging.getLogger(__name__) -db_state_default = {"closed": None, "conn": None, "ctx": None, "transactions": None} -db_state = ContextVar("db_state", default=db_state_default.copy()) +db_state_default = {'closed': None, 'conn': None, 'ctx': None, 'transactions': None} +db_state = ContextVar('db_state', default=db_state_default.copy()) class PeeweeConnectionState(object): def __init__(self, **kwargs): - super().__setattr__("_state", db_state) + super().__setattr__('_state', db_state) super().__init__(**kwargs) def __setattr__(self, name, value): @@ -30,10 +30,10 @@ def __getattr__(self, name): class CustomReconnectMixin(ReconnectMixin): reconnect_errors = ( # psycopg2 - (OperationalError, "termin"), - (InterfaceError, "closed"), + (OperationalError, 'termin'), + (InterfaceError, 'closed'), # peewee - (PeeWeeInterfaceError, "closed"), + (PeeWeeInterfaceError, 'closed'), ) @@ -43,23 +43,21 @@ class ReconnectingPostgresqlDatabase(CustomReconnectMixin, PostgresqlDatabase): def register_connection(db_url): # Check if using SQLCipher protocol - if db_url.startswith("sqlite+sqlcipher://"): - database_password = os.environ.get("DATABASE_PASSWORD") - if not database_password or database_password.strip() == "": - raise ValueError( - "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs" - ) + if db_url.startswith('sqlite+sqlcipher://'): + database_password = os.environ.get('DATABASE_PASSWORD') + if not database_password or database_password.strip() == '': + raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs') from playhouse.sqlcipher_ext import SqlCipherDatabase # Parse the database path from SQLCipher URL # Convert sqlite+sqlcipher:///path/to/db.sqlite to /path/to/db.sqlite - db_path = db_url.replace("sqlite+sqlcipher://", "") + db_path = db_url.replace('sqlite+sqlcipher://', '') # Use Peewee's native SqlCipherDatabase with encryption db = SqlCipherDatabase(db_path, passphrase=database_password) db.autoconnect = True db.reuse_if_open = True - log.info("Connected to encrypted SQLite database using SQLCipher") + log.info('Connected to encrypted SQLite database using SQLCipher') else: # Standard database connection (existing logic) @@ -68,7 +66,7 @@ def register_connection(db_url): # Enable autoconnect for SQLite databases, managed by Peewee db.autoconnect = True db.reuse_if_open = True - log.info("Connected to PostgreSQL database") + log.info('Connected to PostgreSQL database') # Get the connection details connection = parse(db_url, unquote_user=True, unquote_password=True) @@ -80,7 +78,7 @@ def register_connection(db_url): # Enable autoconnect for SQLite databases, managed by Peewee db.autoconnect = True db.reuse_if_open = True - log.info("Connected to SQLite database") + log.info('Connected to SQLite database') else: - raise ValueError("Unsupported database connection") + raise ValueError('Unsupported database connection') return db diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index b7d96becde..e8a1d55496 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -295,6 +295,7 @@ BYPASS_WEB_SEARCH_WEB_LOADER, WEB_SEARCH_RESULT_COUNT, WEB_SEARCH_CONCURRENT_REQUESTS, + WEB_FETCH_MAX_CONTENT_LENGTH, WEB_SEARCH_TRUST_ENV, WEB_SEARCH_DOMAIN_FILTER_LIST, OLLAMA_CLOUD_WEB_SEARCH_API_KEY, @@ -392,6 +393,7 @@ EVALUATION_ARENA_MODELS, # WebUI (OAuth) ENABLE_OAUTH_ROLE_MANAGEMENT, + OAUTH_SUB_CLAIM, OAUTH_ROLES_CLAIM, OAUTH_EMAIL_CLAIM, OAUTH_PICTURE_CLAIM, @@ -490,6 +492,7 @@ ENABLE_CUSTOM_MODEL_FALLBACK, LICENSE_KEY, AUDIT_EXCLUDED_PATHS, + AUDIT_INCLUDED_PATHS, AUDIT_LOG_LEVEL, CHANGELOG, REDIS_URL, @@ -563,6 +566,7 @@ from open_webui.utils.plugin import install_tool_and_function_dependencies from open_webui.utils.oauth import ( get_oauth_client_info_with_dynamic_client_registration, + get_oauth_client_info_with_static_credentials, encrypt_data, decrypt_data, OAuthManager, @@ -586,7 +590,7 @@ from open_webui.constants import ERROR_MESSAGES if SAFE_MODE: - print("SAFE MODE ENABLED") + print('SAFE MODE ENABLED') Functions.deactivate_all_functions() logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL) @@ -599,16 +603,16 @@ async def get_response(self, path: str, scope): return await super().get_response(path, scope) except (HTTPException, StarletteHTTPException) as ex: if ex.status_code == 404: - if path.endswith(".js"): + if path.endswith('.js'): # Return 404 for javascript files raise ex else: - return await super().get_response("index.html", scope) + return await super().get_response('index.html', scope) else: raise ex -if LOG_FORMAT != "json": +if LOG_FORMAT != 'json': print(rf""" โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ @@ -619,7 +623,7 @@ async def get_response(self, path: str, scope): v{VERSION} - building the best AI user interface. -{f"Commit: {WEBUI_BUILD_HASH}" if WEBUI_BUILD_HASH != "dev-build" else ""} +{f'Commit: {WEBUI_BUILD_HASH}' if WEBUI_BUILD_HASH != 'dev-build' else ''} https://github.com/open-webui/open-webui """) @@ -647,22 +651,18 @@ async def lifespan(app: FastAPI): # This should be blocking (sync) so functions are not deactivated on first /get_models calls # when the first user lands on the / route. - log.info("Installing external dependencies of functions and tools...") + log.info('Installing external dependencies of functions and tools...') install_tool_and_function_dependencies() app.state.redis = get_redis_connection( redis_url=REDIS_URL, - redis_sentinels=get_sentinels_from_env( - REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT - ), + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), redis_cluster=REDIS_CLUSTER, async_mode=True, ) if app.state.redis is not None: - app.state.redis_task_command_listener = asyncio.create_task( - redis_task_command_listener(app) - ) + app.state.redis_task_command_listener = asyncio.create_task(redis_task_command_listener(app)) if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0: limiter = anyio.to_thread.current_default_thread_limiter() @@ -677,67 +677,71 @@ async def lifespan(app: FastAPI): Request( # Creating a mock request object to pass to get_all_models { - "type": "http", - "asgi.version": "3.0", - "asgi.spec_version": "2.0", - "method": "GET", - "path": "/internal", - "query_string": b"", - "headers": Headers({}).raw, - "client": ("127.0.0.1", 12345), - "server": ("127.0.0.1", 80), - "scheme": "http", - "app": app, + 'type': 'http', + 'asgi.version': '3.0', + 'asgi.spec_version': '2.0', + 'method': 'GET', + 'path': '/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 12345), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': app, } ), None, ) except Exception as e: - log.warning(f"Failed to pre-fetch models at startup: {e}") + log.warning(f'Failed to pre-fetch models at startup: {e}') # Pre-fetch tool server specs so the first request doesn't pay the latency cost if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: - log.info("Initializing tool servers...") + log.info('Initializing tool servers...') try: mock_request = Request( { - "type": "http", - "asgi.version": "3.0", - "asgi.spec_version": "2.0", - "method": "GET", - "path": "/internal", - "query_string": b"", - "headers": Headers({}).raw, - "client": ("127.0.0.1", 12345), - "server": ("127.0.0.1", 80), - "scheme": "http", - "app": app, + 'type': 'http', + 'asgi.version': '3.0', + 'asgi.spec_version': '2.0', + 'method': 'GET', + 'path': '/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 12345), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': app, } ) await set_tool_servers(mock_request) - log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)") + log.info(f'Initialized {len(app.state.TOOL_SERVERS)} tool server(s)') await set_terminal_servers(mock_request) - log.info( - f"Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)" - ) + log.info(f'Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)') except Exception as e: - log.warning(f"Failed to initialize tool/terminal servers at startup: {e}") + log.warning(f'Failed to initialize tool/terminal servers at startup: {e}') + + # Mark application as ready to accept traffic from a startup perspective. + app.state.startup_complete = True yield - if hasattr(app.state, "redis_task_command_listener"): + if hasattr(app.state, 'redis_task_command_listener'): app.state.redis_task_command_listener.cancel() app = FastAPI( - title="Open WebUI", - docs_url="/docs" if ENV == "dev" else None, - openapi_url="/openapi.json" if ENV == "dev" else None, + title='Open WebUI', + docs_url='/docs' if ENV == 'dev' else None, + openapi_url='/openapi.json' if ENV == 'dev' else None, redoc_url=None, lifespan=lifespan, ) +# Used by readiness checks to gate traffic until startup work is done. +app.state.startup_complete = False + # For Open WebUI OIDC/OAuth2 oauth_manager = OAuthManager(app) app.state.oauth_manager = oauth_manager @@ -858,9 +862,7 @@ async def lifespan(app: FastAPI): app.state.config.SMTP_SENT_FROM = SMTP_SENT_FROM app.state.config.ENABLE_API_KEYS = ENABLE_API_KEYS -app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ( - ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS -) +app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS app.state.config.API_KEYS_ALLOWED_ENDPOINTS = API_KEYS_ALLOWED_ENDPOINTS app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN @@ -905,17 +907,18 @@ async def lifespan(app: FastAPI): from open_webui.utils.access_control import migrate_access_control connections = app.state.config.TOOL_SERVER_CONNECTIONS -if any("access_control" in c.get("config", {}) for c in connections): +if any('access_control' in c.get('config', {}) for c in connections): for connection in connections: - migrate_access_control(connection.get("config", {})) + migrate_access_control(connection.get('config', {})) app.state.config.TOOL_SERVER_CONNECTIONS = connections arena_models = app.state.config.EVALUATION_ARENA_MODELS -if any("access_control" in m.get("meta", {}) for m in arena_models): +if any('access_control' in m.get('meta', {}) for m in arena_models): for model in arena_models: - migrate_access_control(model.get("meta", {})) + migrate_access_control(model.get('meta', {})) app.state.config.EVALUATION_ARENA_MODELS = arena_models +app.state.config.OAUTH_SUB_CLAIM = OAUTH_SUB_CLAIM app.state.config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM app.state.config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM app.state.config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM @@ -981,9 +984,7 @@ async def lifespan(app: FastAPI): app.state.config.RAG_FULL_CONTEXT = RAG_FULL_CONTEXT app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = BYPASS_EMBEDDING_AND_RETRIEVAL app.state.config.ENABLE_RAG_HYBRID_SEARCH = ENABLE_RAG_HYBRID_SEARCH -app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = ( - ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS -) +app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS = ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERIFICATION app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE @@ -994,9 +995,7 @@ async def lifespan(app: FastAPI): app.state.config.DATALAB_MARKER_FORCE_OCR = DATALAB_MARKER_FORCE_OCR app.state.config.DATALAB_MARKER_PAGINATE = DATALAB_MARKER_PAGINATE app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR = DATALAB_MARKER_STRIP_EXISTING_OCR -app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = ( - DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION -) +app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION = DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION app.state.config.DATALAB_MARKER_FORMAT_LINES = DATALAB_MARKER_FORMAT_LINES app.state.config.DATALAB_MARKER_USE_LLM = DATALAB_MARKER_USE_LLM app.state.config.DATALAB_MARKER_OUTPUT_FORMAT = DATALAB_MARKER_OUTPUT_FORMAT @@ -1018,9 +1017,7 @@ async def lifespan(app: FastAPI): app.state.config.MINERU_PARAMS = MINERU_PARAMS app.state.config.TEXT_SPLITTER = RAG_TEXT_SPLITTER -app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = ( - ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER -) +app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER app.state.config.TIKTOKEN_ENCODING_NAME = TIKTOKEN_ENCODING_NAME @@ -1064,15 +1061,14 @@ async def lifespan(app: FastAPI): app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = WEB_SEARCH_DOMAIN_FILTER_LIST app.state.config.WEB_SEARCH_RESULT_COUNT = WEB_SEARCH_RESULT_COUNT app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS +app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH = WEB_FETCH_MAX_CONTENT_LENGTH app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS app.state.config.WEB_LOADER_TIMEOUT = WEB_LOADER_TIMEOUT app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV -app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( - BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL -) +app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = BYPASS_WEB_SEARCH_WEB_LOADER app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION @@ -1135,13 +1131,8 @@ async def lifespan(app: FastAPI): app.state.YOUTUBE_LOADER_TRANSLATION = None try: - app.state.ef = get_ef( - app.state.config.RAG_EMBEDDING_ENGINE, app.state.config.RAG_EMBEDDING_MODEL - ) - if ( - app.state.config.ENABLE_RAG_HYBRID_SEARCH - and not app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL - ): + app.state.ef = get_ef(app.state.config.RAG_EMBEDDING_ENGINE, app.state.config.RAG_EMBEDDING_MODEL) + if app.state.config.ENABLE_RAG_HYBRID_SEARCH and not app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: app.state.rf = get_rf( app.state.config.RAG_RERANKING_ENGINE, app.state.config.RAG_RERANKING_MODEL, @@ -1152,7 +1143,7 @@ async def lifespan(app: FastAPI): else: app.state.rf = None except Exception as e: - log.error(f"Error updating models: {e}") + log.error(f'Error updating models: {e}') pass app.state.EMBEDDING_FUNCTION = get_embedding_function( @@ -1161,26 +1152,26 @@ async def lifespan(app: FastAPI): embedding_function=app.state.ef, url=( app.state.config.RAG_OPENAI_API_BASE_URL - if app.state.config.RAG_EMBEDDING_ENGINE == "openai" + if app.state.config.RAG_EMBEDDING_ENGINE == 'openai' else ( app.state.config.RAG_OLLAMA_BASE_URL - if app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + if app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' else app.state.config.RAG_AZURE_OPENAI_BASE_URL ) ), key=( app.state.config.RAG_OPENAI_API_KEY - if app.state.config.RAG_EMBEDDING_ENGINE == "openai" + if app.state.config.RAG_EMBEDDING_ENGINE == 'openai' else ( app.state.config.RAG_OLLAMA_API_KEY - if app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + if app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' else app.state.config.RAG_AZURE_OPENAI_API_KEY ) ), embedding_batch_size=app.state.config.RAG_EMBEDDING_BATCH_SIZE, azure_api_version=( app.state.config.RAG_AZURE_OPENAI_API_VERSION - if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" + if app.state.config.RAG_EMBEDDING_ENGINE == 'azure_openai' else None ), enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING, @@ -1204,9 +1195,7 @@ async def lifespan(app: FastAPI): app.state.config.CODE_EXECUTION_JUPYTER_URL = CODE_EXECUTION_JUPYTER_URL app.state.config.CODE_EXECUTION_JUPYTER_AUTH = CODE_EXECUTION_JUPYTER_AUTH app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = CODE_EXECUTION_JUPYTER_AUTH_TOKEN -app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = ( - CODE_EXECUTION_JUPYTER_AUTH_PASSWORD -) +app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = CODE_EXECUTION_JUPYTER_AUTH_PASSWORD app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = CODE_EXECUTION_JUPYTER_TIMEOUT app.state.config.ENABLE_CODE_INTERPRETER = ENABLE_CODE_INTERPRETER @@ -1215,12 +1204,8 @@ async def lifespan(app: FastAPI): app.state.config.CODE_INTERPRETER_JUPYTER_URL = CODE_INTERPRETER_JUPYTER_URL app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = CODE_INTERPRETER_JUPYTER_AUTH -app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = ( - CODE_INTERPRETER_JUPYTER_AUTH_TOKEN -) -app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = ( - CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD -) +app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = CODE_INTERPRETER_JUPYTER_AUTH_TOKEN +app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = CODE_INTERPRETER_JUPYTER_TIMEOUT ######################################## @@ -1295,9 +1280,7 @@ async def lifespan(app: FastAPI): app.state.config.AUDIO_STT_MISTRAL_API_KEY = AUDIO_STT_MISTRAL_API_KEY app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = AUDIO_STT_MISTRAL_API_BASE_URL -app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = ( - AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS -) +app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE @@ -1338,23 +1321,13 @@ async def lifespan(app: FastAPI): app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE -app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = ( - IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE -) -app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = ( - FOLLOW_UP_GENERATION_PROMPT_TEMPLATE -) +app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE +app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = FOLLOW_UP_GENERATION_PROMPT_TEMPLATE -app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = ( - TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE -) +app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = QUERY_GENERATION_PROMPT_TEMPLATE -app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = ( - AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE -) -app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = ( - AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH -) +app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE +app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH app.state.config.VOICE_MODE_PROMPT_TEMPLATE = VOICE_MODE_PROMPT_TEMPLATE ######################################## @@ -1365,25 +1338,13 @@ async def lifespan(app: FastAPI): app.state.config.CREDIT_NO_CREDIT_MSG = CREDIT_NO_CREDIT_MSG app.state.config.CREDIT_EXCHANGE_RATIO = CREDIT_EXCHANGE_RATIO app.state.config.CREDIT_DEFAULT_CREDIT = CREDIT_DEFAULT_CREDIT -app.state.config.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE = ( - USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE -) +app.state.config.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE = USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE app.state.config.USAGE_DEFAULT_ENCODING_MODEL = USAGE_DEFAULT_ENCODING_MODEL -app.state.config.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE = ( - USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE -) -app.state.config.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE = ( - USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE -) -app.state.config.USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE = ( - USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE -) -app.state.config.USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE = ( - USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE -) -app.state.config.USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE = ( - USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE -) +app.state.config.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE = USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE +app.state.config.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE = USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE +app.state.config.USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE = USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE +app.state.config.USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE = USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE +app.state.config.USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE = USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE app.state.config.USAGE_CALCULATE_MINIMUM_COST = USAGE_CALCULATE_MINIMUM_COST app.state.config.USAGE_CUSTOM_PRICE_CONFIG = USAGE_CUSTOM_PRICE_CONFIG app.state.config.EZFP_PAY_PRIORITY = EZFP_PAY_PRIORITY @@ -1416,36 +1377,36 @@ async def lifespan(app: FastAPI): class RedirectMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): # Check if the request is a GET request - if request.method == "GET": + if request.method == 'GET': path = request.url.path query_params = dict(parse_qs(urlparse(str(request.url)).query)) redirect_params = {} # Check for the specific watch path and the presence of 'v' parameter - if path.endswith("/watch") and "v" in query_params: + if path.endswith('/watch') and 'v' in query_params: # Extract the first 'v' parameter - youtube_video_id = query_params["v"][0] - redirect_params["youtube"] = youtube_video_id + youtube_video_id = query_params['v'][0] + redirect_params['youtube'] = youtube_video_id - if "shared" in query_params and len(query_params["shared"]) > 0: + if 'shared' in query_params and len(query_params['shared']) > 0: # PWA share_target support - text = query_params["shared"][0] + text = query_params['shared'][0] if text: - urls = re.match(r"https://\S+", text) + urls = re.match(r'https://\S+', text) if urls: from open_webui.retrieval.loaders.youtube import _parse_video_id if youtube_video_id := _parse_video_id(urls[0]): - redirect_params["youtube"] = youtube_video_id + redirect_params['youtube'] = youtube_video_id else: - redirect_params["load-url"] = urls[0] + redirect_params['load-url'] = urls[0] else: - redirect_params["q"] = text + redirect_params['q'] = text if redirect_params: - redirect_url = f"/?{urlencode(redirect_params)}" + redirect_url = f'/?{urlencode(redirect_params)}' return RedirectResponse(url=redirect_url) # Proceed with the normal flow of other requests @@ -1462,25 +1423,23 @@ def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): - if scope["type"] == "http": + if scope['type'] == 'http': request = Request(scope) - auth_header = request.headers.get("Authorization") + auth_header = request.headers.get('Authorization') token = None if auth_header: - parts = auth_header.split(" ", 1) + parts = auth_header.split(' ', 1) if len(parts) == 2: token = parts[1] # Only apply restrictions if an sk- API key is used - if token and token.startswith("sk-"): + if token and token.startswith('sk-'): # Check if restrictions are enabled if app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS: allowed_paths = [ path.strip() - for path in str( - app.state.config.API_KEYS_ALLOWED_ENDPOINTS - ).split(",") + for path in str(app.state.config.API_KEYS_ALLOWED_ENDPOINTS).split(',') if path.strip() ] @@ -1488,17 +1447,13 @@ async def __call__(self, scope, receive, send): # Match exact path or prefix path is_allowed = any( - request_path == allowed - or request_path.startswith(allowed + "/") - for allowed in allowed_paths + request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths ) if not is_allowed: await JSONResponse( status_code=status.HTTP_403_FORBIDDEN, - content={ - "detail": "API key not allowed to access this endpoint." - }, + content={'detail': 'API key not allowed to access this endpoint.'}, )(scope, receive, send) return @@ -1508,7 +1463,7 @@ async def __call__(self, scope, receive, send): app.add_middleware(APIKeyRestrictionMiddleware) -@app.middleware("http") +@app.middleware('http') async def commit_session_after_request(request: Request, call_next): response = await call_next(request) # log.debug("Commit session after request") @@ -1522,51 +1477,44 @@ async def commit_session_after_request(request: Request, call_next): return response -@app.middleware("http") +@app.middleware('http') async def check_url(request: Request, call_next): start_time = int(time.time()) - request.state.token = get_http_authorization_cred( - request.headers.get("Authorization") - ) + request.state.token = get_http_authorization_cred(request.headers.get('Authorization')) # Fallback to cookie token for browser sessions - if request.state.token is None and request.cookies.get("token"): + if request.state.token is None and request.cookies.get('token'): from fastapi.security import HTTPAuthorizationCredentials - request.state.token = HTTPAuthorizationCredentials( - scheme="Bearer", credentials=request.cookies.get("token") - ) + request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=request.cookies.get('token')) # Fallback to x-api-key header for Anthropic Messages API routes - if request.state.token is None and request.headers.get("x-api-key"): + if request.state.token is None and request.headers.get('x-api-key'): request_path = request.url.path - if request_path in ("/api/message", "/api/v1/messages"): + if request_path in ('/api/message', '/api/v1/messages') or request_path.startswith('/ollama/v1/messages'): from fastapi.security import HTTPAuthorizationCredentials request.state.token = HTTPAuthorizationCredentials( - scheme="Bearer", credentials=request.headers.get("x-api-key") + scheme='Bearer', credentials=request.headers.get('x-api-key') ) request.state.enable_api_keys = app.state.config.ENABLE_API_KEYS response = await call_next(request) process_time = int(time.time()) - start_time - response.headers["X-Process-Time"] = str(process_time) + response.headers['X-Process-Time'] = str(process_time) return response -@app.middleware("http") +@app.middleware('http') async def inspect_websocket(request: Request, call_next): - if ( - "/ws/socket.io" in request.url.path - and request.query_params.get("transport") == "websocket" - ): - upgrade = (request.headers.get("Upgrade") or "").lower() - connection = (request.headers.get("Connection") or "").lower().split(",") + if '/ws/socket.io' in request.url.path and request.query_params.get('transport') == 'websocket': + upgrade = (request.headers.get('Upgrade') or '').lower() + connection = (request.headers.get('Connection') or '').lower().split(',') # Check that there's the correct headers for an upgrade, else reject the connection # This is to work around this upstream issue: https://github.com/miguelgrinberg/python-engineio/issues/367 - if upgrade != "websocket" or "upgrade" not in connection: + if upgrade != 'websocket' or 'upgrade' not in connection: return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": "Invalid WebSocket upgrade request"}, + content={'detail': 'Invalid WebSocket upgrade request'}, ) return await call_next(request) @@ -1575,62 +1523,60 @@ async def inspect_websocket(request: Request, call_next): CORSMiddleware, allow_origins=CORS_ALLOW_ORIGIN, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=['*'], + allow_headers=['*'], ) -app.mount("/ws", socket_app) +app.mount('/ws', socket_app) -app.include_router(ollama.router, prefix="/ollama", tags=["ollama"]) -app.include_router(openai.router, prefix="/openai", tags=["openai"]) +app.include_router(ollama.router, prefix='/ollama', tags=['ollama']) +app.include_router(openai.router, prefix='/openai', tags=['openai']) -app.include_router(pipelines.router, prefix="/api/v1/pipelines", tags=["pipelines"]) -app.include_router(tasks.router, prefix="/api/v1/tasks", tags=["tasks"]) -app.include_router(images.router, prefix="/api/v1/images", tags=["images"]) +app.include_router(pipelines.router, prefix='/api/v1/pipelines', tags=['pipelines']) +app.include_router(tasks.router, prefix='/api/v1/tasks', tags=['tasks']) +app.include_router(images.router, prefix='/api/v1/images', tags=['images']) -app.include_router(audio.router, prefix="/api/v1/audio", tags=["audio"]) -app.include_router(retrieval.router, prefix="/api/v1/retrieval", tags=["retrieval"]) +app.include_router(audio.router, prefix='/api/v1/audio', tags=['audio']) +app.include_router(retrieval.router, prefix='/api/v1/retrieval', tags=['retrieval']) -app.include_router(configs.router, prefix="/api/v1/configs", tags=["configs"]) +app.include_router(configs.router, prefix='/api/v1/configs', tags=['configs']) -app.include_router(auths.router, prefix="/api/v1/auths", tags=["auths"]) -app.include_router(users.router, prefix="/api/v1/users", tags=["users"]) +app.include_router(auths.router, prefix='/api/v1/auths', tags=['auths']) +app.include_router(users.router, prefix='/api/v1/users', tags=['users']) -app.include_router(credit.router, prefix="/api/v1/credit", tags=["credit"]) +app.include_router(credit.router, prefix='/api/v1/credit', tags=['credit']) -app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"]) -app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"]) -app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"]) +app.include_router(channels.router, prefix='/api/v1/channels', tags=['channels']) +app.include_router(chats.router, prefix='/api/v1/chats', tags=['chats']) +app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes']) -app.include_router(models.router, prefix="/api/v1/models", tags=["models"]) -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"]) -app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"]) +app.include_router(models.router, prefix='/api/v1/models', tags=['models']) +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']) +app.include_router(skills.router, prefix='/api/v1/skills', tags=['skills']) -app.include_router(memories.router, prefix="/api/v1/memories", tags=["memories"]) -app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"]) -app.include_router(groups.router, prefix="/api/v1/groups", tags=["groups"]) -app.include_router(files.router, prefix="/api/v1/files", tags=["files"]) -app.include_router(functions.router, prefix="/api/v1/functions", tags=["functions"]) -app.include_router( - evaluations.router, prefix="/api/v1/evaluations", tags=["evaluations"] -) +app.include_router(memories.router, prefix='/api/v1/memories', tags=['memories']) +app.include_router(folders.router, prefix='/api/v1/folders', tags=['folders']) +app.include_router(groups.router, prefix='/api/v1/groups', tags=['groups']) +app.include_router(files.router, prefix='/api/v1/files', tags=['files']) +app.include_router(functions.router, prefix='/api/v1/functions', tags=['functions']) +app.include_router(evaluations.router, prefix='/api/v1/evaluations', tags=['evaluations']) if ENABLE_ADMIN_ANALYTICS: - app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"]) -app.include_router(utils.router, prefix="/api/v1/utils", tags=["utils"]) -app.include_router(terminals.router, prefix="/api/v1/terminals", tags=["terminals"]) + app.include_router(analytics.router, prefix='/api/v1/analytics', tags=['analytics']) +app.include_router(utils.router, prefix='/api/v1/utils', tags=['utils']) +app.include_router(terminals.router, prefix='/api/v1/terminals', tags=['terminals']) # SCIM 2.0 API for identity management if ENABLE_SCIM: - app.include_router(scim.router, prefix="/api/v1/scim/v2", tags=["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}") + logger.error(f'Invalid audit level: {AUDIT_LOG_LEVEL}. Error: {e}') audit_level = AuditLevel.NONE if audit_level != AuditLevel.NONE: @@ -1638,6 +1584,7 @@ async def inspect_websocket(request: Request, call_next): AuditLoggingMiddleware, audit_level=audit_level, excluded_paths=AUDIT_EXCLUDED_PATHS, + included_paths=AUDIT_INCLUDED_PATHS, max_body_size=MAX_BODY_LOG_SIZE, ) @@ -1649,20 +1596,18 @@ async def inspect_websocket(request: Request, call_next): ################################## -@app.get("/api/models") -@app.get("/api/v1/models") # Experimental: Compatibility with OpenAI API -async def get_models( - request: Request, refresh: bool = False, user=Depends(get_verified_user) -): +@app.get('/api/models') +@app.get('/api/v1/models') # Experimental: Compatibility with OpenAI API +async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)): def change_preset_model_price(models: list[dict]): for model in models: - base_model_id = model.get("info", {}).get("base_model_id") + base_model_id = model.get('info', {}).get('base_model_id') if not base_model_id: continue - base_model = Models.get_model_by_id(model["info"]["base_model_id"]) + base_model = Models.get_model_by_id(model['info']['base_model_id']) if not base_model: continue - model["info"]["price"] = base_model.price + model['info']['price'] = base_model.price return models all_models = await get_all_models(request, refresh=refresh, user=user) @@ -1670,25 +1615,22 @@ def change_preset_model_price(models: list[dict]): models = [] for model in all_models: # Filter out filter pipelines - if "pipeline" in model and model["pipeline"].get("type", None) == "filter": + if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter': continue # 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) + if model.get('info', {}).get('meta', {}).get('profile_image_url'): + model['info']['meta'].pop('profile_image_url', None) try: - model_tags = [ - tag.get("name") - for tag in model.get("info", {}).get("meta", {}).get("tags", []) - ] - tags = [tag.get("name") for tag in model.get("tags", [])] + model_tags = [tag.get('name') for tag in model.get('info', {}).get('meta', {}).get('tags', [])] + tags = [tag.get('name') for tag in model.get('tags', [])] tags = list(set(model_tags + tags)) - model["tags"] = [{"name": tag} for tag in tags] + model['tags'] = [{'name': tag} for tag in tags] except Exception as e: - log.debug(f"Error processing model tags: {e}") - model["tags"] = [] + log.debug(f'Error processing model tags: {e}') + model['tags'] = [] pass models.append(model) @@ -1699,23 +1641,23 @@ def change_preset_model_price(models: list[dict]): # Sort models by order list priority, with fallback for those not in the list models.sort( key=lambda model: ( - model_order_dict.get(model.get("id", ""), float("inf")), - (model.get("name", "") or ""), + model_order_dict.get(model.get('id', ''), float('inf')), + (model.get('name', '') or ''), ) ) models = 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])}" + f'/api/models returned filtered models accessible to the user: {json.dumps([model.get("id") for model in models])}' ) - return {"data": change_preset_model_price(models)} + return {'data': change_preset_model_price(models)} -@app.get("/api/models/base") +@app.get('/api/models/base') async def get_base_models(request: Request, user=Depends(get_admin_user)): models = await get_all_base_models(request, user=user) - return {"data": models} + return {'data': models} ################################## @@ -1723,11 +1665,9 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)): ################################## -@app.post("/api/embeddings") -@app.post("/api/v1/embeddings") # Experimental: Compatibility with OpenAI API -async def embeddings( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@app.post('/api/embeddings') +@app.post('/api/v1/embeddings') # Experimental: Compatibility with OpenAI API +async def embeddings(request: Request, form_data: dict, user=Depends(get_verified_user)): """ OpenAI-compatible embeddings endpoint. @@ -1750,8 +1690,8 @@ async def embeddings( return await generate_embeddings(request, form_data, user) -@app.post("/api/chat/completions") -@app.post("/api/v1/chat/completions") # Experimental: Compatibility with OpenAI API +@app.post('/api/chat/completions') +@app.post('/api/v1/chat/completions') # Experimental: Compatibility with OpenAI API async def chat_completion( request: Request, form_data: dict, @@ -1762,24 +1702,22 @@ async def chat_completion( if not request.app.state.MODELS: await get_all_models(request, user=user) - model_id = form_data.get("model", None) - model_item = form_data.pop("model_item", {}) - tasks = form_data.pop("background_tasks", None) + model_id = form_data.get('model', None) + model_item = form_data.pop('model_item', {}) + tasks = form_data.pop('background_tasks', None) metadata = {} try: model_info = None - if not model_item.get("direct", False): + if not model_item.get('direct', False): if model_id not in request.app.state.MODELS: - raise Exception("Model not found") + raise Exception('Model not found') model = request.app.state.MODELS[model_id] model_info = Models.get_model_by_id(model_id) # Check if user has access to the model - if not BYPASS_MODEL_ACCESS_CONTROL and ( - user.role != "admin" or not BYPASS_ADMIN_ACCESS_CONTROL - ): + if not BYPASS_MODEL_ACCESS_CONTROL and (user.role != 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL): try: check_model_access(user, model) except Exception as e: @@ -1791,16 +1729,10 @@ async def chat_completion( request.state.model = model # Model params: global defaults as base, per-model overrides win - default_model_params = ( - getattr(request.app.state.config, "DEFAULT_MODEL_PARAMS", None) or {} - ) + default_model_params = getattr(request.app.state.config, 'DEFAULT_MODEL_PARAMS', None) or {} model_info_params = { **default_model_params, - **( - model_info.params.model_dump() - if model_info and model_info.params - else {} - ), + **(model_info.params.model_dump() if model_info and model_info.params else {}), } # Check base model existence for custom models @@ -1808,81 +1740,68 @@ async def chat_completion( base_model_id = model_info.base_model_id if base_model_id not in request.app.state.MODELS: if ENABLE_CUSTOM_MODEL_FALLBACK: - default_models = ( - request.app.state.config.DEFAULT_MODELS or "" - ).split(",") + default_models = (request.app.state.config.DEFAULT_MODELS or '').split(',') - fallback_model_id = ( - default_models[0].strip() if default_models[0] else None - ) + fallback_model_id = default_models[0].strip() if default_models[0] else None - if ( - fallback_model_id - and fallback_model_id in request.app.state.MODELS - ): + if fallback_model_id and fallback_model_id in request.app.state.MODELS: # Update model and form_data so routing uses the fallback model's type model = request.app.state.MODELS[fallback_model_id] - form_data["model"] = fallback_model_id + form_data['model'] = fallback_model_id else: - raise Exception("Model not found") + raise Exception('Model not found') else: - raise Exception("Model not found") + raise Exception('Model not found') # Chat Params - stream_delta_chunk_size = form_data.get("params", {}).get( - "stream_delta_chunk_size" - ) - reasoning_tags = form_data.get("params", {}).get("reasoning_tags") + stream_delta_chunk_size = form_data.get('params', {}).get('stream_delta_chunk_size') + reasoning_tags = form_data.get('params', {}).get('reasoning_tags') # Model Params - if model_info_params.get("stream_response") is not None: - form_data["stream"] = model_info_params.get("stream_response") + if model_info_params.get('stream_response') is not None: + form_data['stream'] = model_info_params.get('stream_response') - if model_info_params.get("stream_delta_chunk_size"): - stream_delta_chunk_size = model_info_params.get("stream_delta_chunk_size") + if model_info_params.get('stream_delta_chunk_size'): + stream_delta_chunk_size = model_info_params.get('stream_delta_chunk_size') - if model_info_params.get("reasoning_tags") is not None: - reasoning_tags = model_info_params.get("reasoning_tags") + if model_info_params.get('reasoning_tags') is not None: + reasoning_tags = model_info_params.get('reasoning_tags') metadata = { - "user_id": user.id, - "chat_id": form_data.pop("chat_id", None), - "message_id": form_data.pop("id", None), - "parent_message": form_data.pop("parent_message", None), - "parent_message_id": form_data.pop("parent_id", None), - "session_id": form_data.pop("session_id", None), - "filter_ids": form_data.pop("filter_ids", []), - "tool_ids": form_data.get("tool_ids", None), - "tool_servers": form_data.pop("tool_servers", None), - "files": form_data.get("files", None), - "features": form_data.get("features", {}), - "variables": form_data.get("variables", {}), - "model": model, - "direct": model_item.get("direct", False), - "params": { - "stream_delta_chunk_size": stream_delta_chunk_size, - "reasoning_tags": reasoning_tags, - "function_calling": ( - "native" + 'user_id': user.id, + 'chat_id': form_data.pop('chat_id', None), + 'message_id': form_data.pop('id', None), + 'parent_message': form_data.pop('parent_message', None), + 'parent_message_id': form_data.pop('parent_id', None), + 'session_id': form_data.pop('session_id', None), + 'filter_ids': form_data.pop('filter_ids', []), + 'tool_ids': form_data.get('tool_ids', None), + 'tool_servers': form_data.pop('tool_servers', None), + 'files': form_data.get('files', None), + 'features': form_data.get('features', {}), + 'variables': form_data.get('variables', {}), + 'model': model, + 'direct': model_item.get('direct', False), + 'params': { + 'stream_delta_chunk_size': stream_delta_chunk_size, + 'reasoning_tags': reasoning_tags, + 'function_calling': ( + 'native' if ( - form_data.get("params", {}).get("function_calling") == "native" - or model_info_params.get("function_calling") == "native" + form_data.get('params', {}).get('function_calling') == 'native' + or model_info_params.get('function_calling') == 'native' ) - else "default" + else 'default' ), }, } - if metadata.get("chat_id") and user: - if not metadata["chat_id"].startswith( - "local:" - ): # temporary chats are not stored - + if metadata.get('chat_id') and user: + if not metadata['chat_id'].startswith('local:'): # temporary chats are not stored # Verify chat ownership โ€” lightweight EXISTS check avoids # deserializing the full chat JSON blob just to confirm the row exists if ( - not Chats.is_chat_owner(metadata["chat_id"], user.id) - and user.role != "admin" + not Chats.is_chat_owner(metadata['chat_id'], user.id) and user.role != 'admin' ): # admins can access any chat raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1890,29 +1809,29 @@ async def chat_completion( ) # Insert chat files from parent message if any - parent_message = metadata.get("parent_message") or {} - parent_message_files = parent_message.get("files", []) + parent_message = metadata.get('parent_message') or {} + parent_message_files = parent_message.get('files', []) if parent_message_files: try: Chats.insert_chat_files( - metadata["chat_id"], - parent_message.get("id"), + metadata['chat_id'], + parent_message.get('id'), [ - file_item.get("id") + file_item.get('id') for file_item in parent_message_files - if file_item.get("type") == "file" + if file_item.get('type') == 'file' ], user.id, ) except Exception as e: - log.debug(f"Error inserting chat files: {e}") + log.debug(f'Error inserting chat files: {e}') pass request.state.metadata = metadata - form_data["metadata"] = metadata + form_data['metadata'] = metadata except Exception as e: - log.debug(f"Error processing chat metadata: {e}") + log.debug(f'Error processing chat metadata: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), @@ -1920,41 +1839,35 @@ async def chat_completion( async def process_chat(request, form_data, user, metadata, model): try: - form_data["metadata"]["features_for_credit"] = form_data["metadata"][ - "features" - ] + form_data['metadata']['features_for_credit'] = form_data['metadata']['features'] - form_data, metadata, events = await process_chat_payload( - request, form_data, user, metadata, model - ) + form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model) response = await chat_completion_handler(request, form_data, user) - if metadata.get("chat_id") and metadata.get("message_id"): + if metadata.get('chat_id') and metadata.get('message_id'): try: - if not metadata["chat_id"].startswith("local:"): + if not metadata['chat_id'].startswith('local:'): Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "parentId": metadata.get("parent_message_id", None), - "model": model_id, + 'parentId': metadata.get('parent_message_id', None), + 'model': model_id, }, ) except Exception: pass - ctx = build_chat_response_context( - request, form_data, user, model, metadata, tasks, events - ) + ctx = build_chat_response_context(request, form_data, user, model, metadata, tasks, events) return await process_chat_response(response, ctx) except asyncio.CancelledError: - log.info("Chat processing was cancelled") + log.info('Chat processing was cancelled') try: event_emitter = get_event_emitter(metadata) await asyncio.shield( event_emitter( - {"type": "chat:tasks:cancel"}, + {'type': 'chat:tasks:cancel'}, ) ) except Exception as e: @@ -1962,68 +1875,62 @@ async def process_chat(request, form_data, user, metadata, model): finally: raise # re-raise to ensure proper task cancellation handling except Exception as e: - log.debug(f"Error processing chat payload: {e}") - if metadata.get("chat_id") and metadata.get("message_id"): + log.debug(f'Error processing chat payload: {e}') + if metadata.get('chat_id') and metadata.get('message_id'): # Update the chat message with the error try: - if not metadata["chat_id"].startswith("local:"): + if not metadata['chat_id'].startswith('local:'): Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "parentId": metadata.get("parent_message_id", None), - "error": {"content": str(e)}, + 'parentId': metadata.get('parent_message_id', None), + 'error': {'content': str(e)}, }, ) event_emitter = get_event_emitter(metadata) await event_emitter( { - "type": "chat:message:error", - "data": {"error": {"content": str(e)}}, + 'type': 'chat:message:error', + 'data': {'error': {'content': str(e)}}, } ) await event_emitter( - {"type": "chat:tasks:cancel"}, + {'type': 'chat:tasks:cancel'}, ) except Exception: pass finally: try: - if mcp_clients := metadata.get("mcp_clients"): + if mcp_clients := metadata.get('mcp_clients'): for client in reversed(mcp_clients.values()): await client.disconnect() except Exception as e: - log.debug(f"Error cleaning up: {e}") + log.debug(f'Error cleaning up: {e}') pass # Emit chat:active=false when task completes try: - if metadata.get("chat_id"): + if metadata.get('chat_id'): event_emitter = get_event_emitter(metadata, update_db=False) if event_emitter: - await event_emitter( - {"type": "chat:active", "data": {"active": False}} - ) + await event_emitter({'type': 'chat:active', 'data': {'active': False}}) except Exception as e: - log.debug(f"Error emitting chat:active: {e}") + log.debug(f'Error emitting chat:active: {e}') - if ( - metadata.get("session_id") - and metadata.get("chat_id") - and metadata.get("message_id") - ): + if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'): # Asynchronous Chat Processing task_id, _ = await create_task( request.app.state.redis, process_chat(request, form_data, user, metadata, model), - id=metadata["chat_id"], + id=metadata['chat_id'], ) # Emit chat:active=true when task starts event_emitter = get_event_emitter(metadata, update_db=False) if event_emitter: - await event_emitter({"type": "chat:active", "data": {"active": True}}) - return {"status": True, "task_id": task_id} + await event_emitter({'type': 'chat:active', 'data': {'active': True}}) + return {'status': True, 'task_id': task_id} else: return await process_chat(request, form_data, user, metadata, model) @@ -2047,8 +1954,8 @@ async def process_chat(request, form_data, user, metadata, model): ) -@app.post("/api/message") -@app.post("/api/v1/messages") # Anthropic Messages API compatible endpoint +@app.post('/api/message') +@app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint async def generate_messages( request: Request, form_data: dict, @@ -2068,7 +1975,7 @@ async def generate_messages( Anthropic's x-api-key header (via middleware translation). """ # Convert Anthropic payload to OpenAI format - requested_model = form_data.get("model", "") + requested_model = form_data.get('model', '') openai_payload = convert_anthropic_to_openai_payload(form_data) @@ -2079,13 +1986,11 @@ 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 - ), - media_type="text/event-stream", + openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model), + media_type='text/event-stream', headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', }, ) elif isinstance(response, dict): @@ -2095,14 +2000,12 @@ async def generate_messages( return response -@app.post("/api/chat/completed") -async def chat_completed( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@app.post('/api/chat/completed') +async def chat_completed(request: Request, form_data: dict, user=Depends(get_verified_user)): try: - model_item = form_data.pop("model_item", {}) + model_item = form_data.pop('model_item', {}) - if model_item.get("direct", False): + if model_item.get('direct', False): request.state.direct = True request.state.model = model_item @@ -2114,14 +2017,12 @@ async def chat_completed( ) -@app.post("/api/chat/actions/{action_id}") -async def chat_action( - request: Request, action_id: str, form_data: dict, user=Depends(get_verified_user) -): +@app.post('/api/chat/actions/{action_id}') +async def chat_action(request: Request, action_id: str, form_data: dict, user=Depends(get_verified_user)): try: - model_item = form_data.pop("model_item", {}) + model_item = form_data.pop('model_item', {}) - if model_item.get("direct", False): + if model_item.get('direct', False): request.state.direct = True request.state.model = model_item @@ -2133,10 +2034,8 @@ async def chat_action( ) -@app.post("/api/tasks/stop/{task_id}") -async def stop_task_endpoint( - request: Request, task_id: str, user=Depends(get_verified_user) -): +@app.post('/api/tasks/stop/{task_id}') +async def stop_task_endpoint(request: Request, task_id: str, user=Depends(get_verified_user)): try: result = await stop_task(request.app.state.redis, task_id) return result @@ -2144,23 +2043,21 @@ async def stop_task_endpoint( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) -@app.get("/api/tasks") +@app.get('/api/tasks') async def list_tasks_endpoint(request: Request, user=Depends(get_verified_user)): - return {"tasks": await list_tasks(request.app.state.redis)} + return {'tasks': await list_tasks(request.app.state.redis)} -@app.get("/api/tasks/chat/{chat_id}") -async def list_tasks_by_chat_id_endpoint( - request: Request, chat_id: str, user=Depends(get_verified_user) -): +@app.get('/api/tasks/chat/{chat_id}') +async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)): chat = Chats.get_chat_by_id(chat_id) if chat is None or chat.user_id != user.id: - return {"task_ids": []} + return {'task_ids': []} task_ids = await list_task_ids_by_item_id(request.app.state.redis, chat_id) - log.debug(f"Task IDs for chat {chat_id}: {task_ids}") - return {"task_ids": task_ids} + log.debug(f'Task IDs for chat {chat_id}: {task_ids}') + return {'task_ids': task_ids} ################################## @@ -2170,19 +2067,19 @@ async def list_tasks_by_chat_id_endpoint( ################################## -@app.get("/api/config") +@app.get('/api/config') async def get_app_config(request: Request): user = None token = None - auth_header = request.headers.get("Authorization") + auth_header = request.headers.get('Authorization') if auth_header: cred = get_http_authorization_cred(auth_header) if cred: token = cred.credentials - if not token and "token" in request.cookies: - token = request.cookies.get("token") + if not token and 'token' in request.cookies: + token = request.cookies.get('token') if token: try: @@ -2191,10 +2088,10 @@ async def get_app_config(request: Request): log.debug(e) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", + detail='Invalid token', ) - if data is not None and "id" in data: - user = Users.get_user_by_id(data["id"]) + if data is not None and 'id' in data: + user = Users.get_user_by_id(data['id']) user_count = Users.get_num_users() onboarding = False @@ -2203,56 +2100,51 @@ async def get_app_config(request: Request): onboarding = user_count == 0 return { - **({"onboarding": True} if onboarding else {}), - "status": True, - "name": app.state.WEBUI_NAME, - "version": VERSION, - "default_locale": str(DEFAULT_LOCALE), - "oauth": { - "providers": { - name: config.get("name", name) - for name, config in OAUTH_PROVIDERS.items() - } - }, - "features": { - "auth": WEBUI_AUTH, - "auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER), - "enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION, - "enable_ldap": app.state.config.ENABLE_LDAP, - "enable_api_keys": app.state.config.ENABLE_API_KEYS, - "enable_signup": app.state.config.ENABLE_SIGNUP, - "enable_login_form": app.state.config.ENABLE_LOGIN_FORM, - "enable_signup_verify": app.state.config.ENABLE_SIGNUP_VERIFY, - "enable_websocket": ENABLE_WEBSOCKET_SUPPORT, - "enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK, - "enable_public_active_users_count": ENABLE_PUBLIC_ACTIVE_USERS_COUNT, - "enable_easter_eggs": ENABLE_EASTER_EGGS, + **({'onboarding': True} if onboarding else {}), + 'status': True, + 'name': app.state.WEBUI_NAME, + 'version': VERSION, + 'default_locale': str(DEFAULT_LOCALE), + 'oauth': {'providers': {name: config.get('name', name) for name, config in OAUTH_PROVIDERS.items()}}, + 'features': { + 'auth': WEBUI_AUTH, + 'auth_trusted_header': bool(app.state.AUTH_TRUSTED_EMAIL_HEADER), + 'enable_signup_password_confirmation': ENABLE_SIGNUP_PASSWORD_CONFIRMATION, + 'enable_ldap': app.state.config.ENABLE_LDAP, + 'enable_api_keys': app.state.config.ENABLE_API_KEYS, + 'enable_signup': app.state.config.ENABLE_SIGNUP, + 'enable_login_form': app.state.config.ENABLE_LOGIN_FORM, + 'enable_signup_verify': app.state.config.ENABLE_SIGNUP_VERIFY, + 'enable_websocket': ENABLE_WEBSOCKET_SUPPORT, + 'enable_version_update_check': ENABLE_VERSION_UPDATE_CHECK, + 'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT, + 'enable_easter_eggs': ENABLE_EASTER_EGGS, **( { - "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, - "enable_folders": app.state.config.ENABLE_FOLDERS, - "folder_max_file_count": app.state.config.FOLDER_MAX_FILE_COUNT, - "enable_channels": app.state.config.ENABLE_CHANNELS, - "enable_notes": app.state.config.ENABLE_NOTES, - "enable_web_search": app.state.config.ENABLE_WEB_SEARCH, - "enable_code_execution": app.state.config.ENABLE_CODE_EXECUTION, - "enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER, - "enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION, - "enable_autocomplete_generation": app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, - "enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING, - "enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING, - "enable_user_webhooks": app.state.config.ENABLE_USER_WEBHOOKS, - "enable_user_status": app.state.config.ENABLE_USER_STATUS, - "enable_admin_export": ENABLE_ADMIN_EXPORT, - "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS, - "enable_admin_analytics": ENABLE_ADMIN_ANALYTICS, - "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, - "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION, - "enable_memories": app.state.config.ENABLE_MEMORIES, + 'enable_direct_connections': app.state.config.ENABLE_DIRECT_CONNECTIONS, + 'enable_folders': app.state.config.ENABLE_FOLDERS, + 'folder_max_file_count': app.state.config.FOLDER_MAX_FILE_COUNT, + 'enable_channels': app.state.config.ENABLE_CHANNELS, + 'enable_notes': app.state.config.ENABLE_NOTES, + 'enable_web_search': app.state.config.ENABLE_WEB_SEARCH, + 'enable_code_execution': app.state.config.ENABLE_CODE_EXECUTION, + 'enable_code_interpreter': app.state.config.ENABLE_CODE_INTERPRETER, + 'enable_image_generation': app.state.config.ENABLE_IMAGE_GENERATION, + 'enable_autocomplete_generation': app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, + 'enable_community_sharing': app.state.config.ENABLE_COMMUNITY_SHARING, + 'enable_message_rating': app.state.config.ENABLE_MESSAGE_RATING, + 'enable_user_webhooks': app.state.config.ENABLE_USER_WEBHOOKS, + 'enable_user_status': app.state.config.ENABLE_USER_STATUS, + 'enable_admin_export': ENABLE_ADMIN_EXPORT, + 'enable_admin_chat_access': ENABLE_ADMIN_CHAT_ACCESS, + 'enable_admin_analytics': ENABLE_ADMIN_ANALYTICS, + 'enable_google_drive_integration': app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + 'enable_onedrive_integration': app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + 'enable_memories': app.state.config.ENABLE_MEMORIES, **( { - "enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL, - "enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS, + 'enable_onedrive_personal': ENABLE_ONEDRIVE_PERSONAL, + 'enable_onedrive_business': ENABLE_ONEDRIVE_BUSINESS, } if app.state.config.ENABLE_ONEDRIVE_INTEGRATION else {} @@ -2262,80 +2154,81 @@ async def get_app_config(request: Request): else {} ), }, - "ui": { - "pending_user_overlay_title": app.state.config.PENDING_USER_OVERLAY_TITLE, - "pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT, - "response_watermark": app.state.config.RESPONSE_WATERMARK, + 'ui': { + 'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE, + 'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT, + 'response_watermark': app.state.config.RESPONSE_WATERMARK, }, **( { - "default_models": app.state.config.DEFAULT_MODELS, - "default_pinned_models": app.state.config.DEFAULT_PINNED_MODELS, - "default_prompt_suggestions": app.state.config.DEFAULT_PROMPT_SUGGESTIONS, - "user_count": user_count, - "code": { - "engine": app.state.config.CODE_EXECUTION_ENGINE, - "interpreter_engine": app.state.config.CODE_INTERPRETER_ENGINE, + 'default_models': app.state.config.DEFAULT_MODELS, + 'default_pinned_models': app.state.config.DEFAULT_PINNED_MODELS, + 'default_prompt_suggestions': app.state.config.DEFAULT_PROMPT_SUGGESTIONS, + 'user_count': user_count, + 'code': { + 'engine': app.state.config.CODE_EXECUTION_ENGINE, + 'interpreter_engine': app.state.config.CODE_INTERPRETER_ENGINE, }, - "audio": { - "tts": { - "engine": app.state.config.TTS_ENGINE, - "voice": app.state.config.TTS_VOICE, - "split_on": app.state.config.TTS_SPLIT_ON, + 'audio': { + 'tts': { + 'engine': app.state.config.TTS_ENGINE, + 'voice': app.state.config.TTS_VOICE, + 'split_on': app.state.config.TTS_SPLIT_ON, }, - "stt": { - "engine": app.state.config.STT_ENGINE, + 'stt': { + 'engine': app.state.config.STT_ENGINE, }, }, - "file": { - "max_size": app.state.config.FILE_MAX_SIZE, - "max_count": app.state.config.FILE_MAX_COUNT, - "image_compression": { - "width": app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, - "height": app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, + 'file': { + 'max_size': app.state.config.FILE_MAX_SIZE, + 'max_count': app.state.config.FILE_MAX_COUNT, + 'image_compression': { + 'width': app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, + 'height': app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, }, }, - "permissions": {**app.state.config.USER_PERMISSIONS}, - "google_drive": { - "client_id": GOOGLE_DRIVE_CLIENT_ID.value, - "api_key": GOOGLE_DRIVE_API_KEY.value, + 'permissions': {**app.state.config.USER_PERMISSIONS}, + 'google_drive': { + 'client_id': GOOGLE_DRIVE_CLIENT_ID.value, + 'api_key': GOOGLE_DRIVE_API_KEY.value, }, - "onedrive": { - "client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL, - "client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS, - "sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value, - "sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value, + 'onedrive': { + 'client_id_personal': ONEDRIVE_CLIENT_ID_PERSONAL, + 'client_id_business': ONEDRIVE_CLIENT_ID_BUSINESS, + 'sharepoint_url': ONEDRIVE_SHAREPOINT_URL.value, + 'sharepoint_tenant_id': ONEDRIVE_SHAREPOINT_TENANT_ID.value, }, - "license_metadata": app.state.LICENSE_METADATA, + 'ui': { + 'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE, + 'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT, + 'response_watermark': app.state.config.RESPONSE_WATERMARK, + }, + 'license_metadata': app.state.LICENSE_METADATA, **( { - "active_entries": app.state.USER_COUNT, + 'active_entries': app.state.USER_COUNT, } - if user.role == "admin" + if user.role == 'admin' else {} ), } - if user is not None and (user.role in ["admin", "user"]) + if user is not None and (user.role in ['admin', 'user']) else { **( { - "ui": { - "pending_user_overlay_title": app.state.config.PENDING_USER_OVERLAY_TITLE, - "pending_user_overlay_content": app.state.config.PENDING_USER_OVERLAY_CONTENT, + 'ui': { + 'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE, + 'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT, } } - if user and user.role == "pending" + if user and user.role == 'pending' else {} ), **( { - "metadata": { - "login_footer": app.state.LICENSE_METADATA.get( - "login_footer", "" - ), - "auth_logo_position": app.state.LICENSE_METADATA.get( - "auth_logo_position", "" - ), + 'metadata': { + 'login_footer': app.state.LICENSE_METADATA.get('login_footer', ''), + 'auth_logo_position': app.state.LICENSE_METADATA.get('auth_logo_position', ''), } } if app.state.LICENSE_METADATA @@ -2350,58 +2243,56 @@ class UrlForm(BaseModel): url: str -@app.get("/api/webhook") +@app.get('/api/webhook') async def get_webhook_url(user=Depends(get_admin_user)): return { - "url": app.state.config.WEBHOOK_URL, + 'url': app.state.config.WEBHOOK_URL, } -@app.post("/api/webhook") +@app.post('/api/webhook') async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)): app.state.config.WEBHOOK_URL = form_data.url app.state.WEBHOOK_URL = app.state.config.WEBHOOK_URL - return {"url": app.state.config.WEBHOOK_URL} + return {'url': app.state.config.WEBHOOK_URL} -@app.get("/api/version") +@app.get('/api/version') async def get_app_version(): return { - "version": VERSION, - "deployment_id": DEPLOYMENT_ID, + 'version': VERSION, + 'deployment_id': DEPLOYMENT_ID, } -@app.get("/api/version/updates") +@app.get('/api/version/updates') async def get_app_latest_release_version(user=Depends(get_verified_user)): if not ENABLE_VERSION_UPDATE_CHECK: - log.debug( - f"Version update check is disabled, returning current version as latest version" - ) - return {"current": VERSION, "latest": VERSION} + log.debug(f'Version update check is disabled, returning current version as latest version') + return {'current': VERSION, 'latest': VERSION} try: timeout = aiohttp.ClientTimeout(total=1) async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get( - "https://api.github.com/repos/ovinc-cn/openwebui/releases/latest", + 'https://api.github.com/repos/ovinc-cn/openwebui/releases/latest', ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: response.raise_for_status() data = await response.json() - latest_version = data["tag_name"] + latest_version = data['tag_name'] - return {"current": VERSION, "latest": latest_version[1:]} + return {'current': VERSION, 'latest': latest_version[1:]} except Exception as e: log.debug(e) - return {"current": VERSION, "latest": VERSION} + return {'current': VERSION, 'latest': VERSION} -@app.get("/api/changelog") +@app.get('/api/changelog') async def get_app_changelog(): return {key: CHANGELOG[key] for idx, key in enumerate(CHANGELOG) if idx < 5} -@app.get("/api/usage") +@app.get('/api/usage') async def get_current_usage(user=Depends(get_verified_user)): """ Get current usage statistics for Open WebUI. @@ -2409,21 +2300,21 @@ async def get_current_usage(user=Depends(get_verified_user)): """ try: # If public visibility is disabled, only allow admins to access this endpoint - if not ENABLE_PUBLIC_ACTIVE_USERS_COUNT and user.role != "admin": + if not ENABLE_PUBLIC_ACTIVE_USERS_COUNT and user.role != 'admin': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied. Only administrators can view usage statistics.", + detail='Access denied. Only administrators can view usage statistics.', ) return { - "model_ids": get_models_in_use(), - "user_count": Users.get_active_user_count(), + 'model_ids': get_models_in_use(), + 'user_count': Users.get_active_user_count(), } except HTTPException: raise except Exception as e: - log.error(f"Error getting usage statistics: {e}") - raise HTTPException(status_code=500, detail="Internal Server Error") + log.error(f'Error getting usage statistics: {e}') + raise HTTPException(status_code=500, detail='Internal Server Error') ############################ @@ -2434,114 +2325,122 @@ async def get_current_usage(user=Depends(get_verified_user)): # Initialize OAuth client manager with any MCP tool servers using OAuth 2.1 if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: for tool_server_connection in app.state.config.TOOL_SERVER_CONNECTIONS: - if tool_server_connection.get("type", "openapi") == "mcp": - server_id = tool_server_connection.get("info", {}).get("id") - auth_type = tool_server_connection.get("auth_type", "none") + if tool_server_connection.get('type', 'openapi') == 'mcp': + server_id = tool_server_connection.get('info', {}).get('id') + auth_type = tool_server_connection.get('auth_type', 'none') - if server_id and auth_type == "oauth_2.1": - oauth_client_info = tool_server_connection.get("info", {}).get( - "oauth_client_info", "" - ) + if server_id and auth_type in ('oauth_2.1', 'oauth_2.1_static'): + oauth_client_info = tool_server_connection.get('info', {}).get('oauth_client_info', '') try: oauth_client_info = decrypt_data(oauth_client_info) app.state.oauth_client_manager.add_client( - f"mcp:{server_id}", + f'mcp:{server_id}', OAuthClientInformationFull(**oauth_client_info), ) except Exception as e: - log.error( - f"Error adding OAuth client for MCP tool server {server_id}: {e}" - ) + log.error(f'Error adding OAuth client for MCP tool server {server_id}: {e}') pass try: if ENABLE_STAR_SESSIONS_MIDDLEWARE: redis_session_store = RedisStore( url=REDIS_URL, - prefix=(f"{REDIS_KEY_PREFIX}:session:" if REDIS_KEY_PREFIX else "session:"), + prefix=(f'{REDIS_KEY_PREFIX}:session:' if REDIS_KEY_PREFIX else 'session:'), ) app.add_middleware(SessionAutoloadMiddleware) app.add_middleware( StarSessionsMiddleware, store=redis_session_store, - cookie_name="owui-session", + cookie_name='owui-session', cookie_same_site=WEBUI_SESSION_COOKIE_SAME_SITE, cookie_https_only=WEBUI_SESSION_COOKIE_SECURE, ) - log.info("Using Redis for session") + log.info('Using Redis for session') else: - raise ValueError("No Redis URL provided") + raise ValueError('No Redis URL provided') except Exception as e: app.add_middleware( SessionMiddleware, secret_key=WEBUI_SECRET_KEY, - session_cookie="owui-session", + session_cookie='owui-session', same_site=WEBUI_SESSION_COOKIE_SAME_SITE, https_only=WEBUI_SESSION_COOKIE_SECURE, ) async def register_client(request, client_id: str) -> bool: - server_type, server_id = client_id.split(":", 1) + server_type, server_id = client_id.split(':', 1) connection = None connection_idx = None for idx, conn in enumerate(request.app.state.config.TOOL_SERVER_CONNECTIONS or []): - if conn.get("type", "openapi") == server_type: - info = conn.get("info", {}) - if info.get("id") == server_id: + if conn.get('type', 'openapi') == server_type: + info = conn.get('info', {}) + if info.get('id') == server_id: connection = conn connection_idx = idx break if connection is None or connection_idx is None: - log.warning( - f"Unable to locate MCP tool server configuration for client {client_id} during re-registration" - ) + log.warning(f'Unable to locate MCP tool server configuration for client {client_id} during re-registration') return False - server_url = connection.get("url") - oauth_server_key = (connection.get("config") or {}).get("oauth_server_key") + server_url = connection.get('url') + auth_type = connection.get('auth_type', 'none') + oauth_server_key = (connection.get('config') or {}).get('oauth_server_key') try: - oauth_client_info = ( - await get_oauth_client_info_with_dynamic_client_registration( + if auth_type == 'oauth_2.1_static': + # Static credentials: rebuild from stored credentials + fresh metadata + existing_client_info = connection.get('info', {}).get('oauth_client_info', '') + if not existing_client_info: + log.error(f'No stored OAuth client info for static client {client_id}') + return False + existing_data = decrypt_data(existing_client_info) + oauth_client_info = await get_oauth_client_info_with_static_credentials( + request, + client_id, + server_url, + oauth_client_id=existing_data.get('client_id', ''), + oauth_client_secret=existing_data.get('client_secret', ''), + ) + else: + oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( request, client_id, server_url, oauth_server_key, ) - ) except Exception as e: - log.error(f"Dynamic client re-registration failed for {client_id}: {e}") + log.error(f'OAuth client re-registration failed for {client_id}: {e}') return False try: - request.app.state.config.TOOL_SERVER_CONNECTIONS[connection_idx] = { + connections = request.app.state.config.TOOL_SERVER_CONNECTIONS + connections[connection_idx] = { **connection, - "info": { - **connection.get("info", {}), - "oauth_client_info": encrypt_data( - oauth_client_info.model_dump(mode="json") - ), + 'info': { + **connection.get('info', {}), + 'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')), }, } + # Re-assign the full list to trigger AppConfig.__setattr__ โ†’ PersistentConfig.save() + # (in-place list mutation via list[idx] = ... does not trigger __setattr__) + request.app.state.config.TOOL_SERVER_CONNECTIONS = connections except Exception as e: - log.error( - f"Failed to persist updated OAuth client info for tool server {client_id}: {e}" - ) + log.error(f'Failed to persist updated OAuth client info for tool server {client_id}: {e}') return False oauth_client_manager.remove_client(client_id) oauth_client_manager.add_client(client_id, oauth_client_info) - log.info(f"Re-registered OAuth client {client_id} for tool server") + log.info(f'Re-registered OAuth client {client_id} for tool server') return True -@app.get("/oauth/clients/{client_id}/authorize") +@app.get('/oauth/clients/{client_id}/authorize') async def oauth_client_authorize( client_id: str, request: Request, @@ -2556,7 +2455,7 @@ async def oauth_client_authorize( if not await oauth_client_manager._preflight_authorization_url(client, client_info): log.info( - "Detected invalid OAuth client %s; attempting re-registration", + 'Detected invalid OAuth client %s; attempting re-registration', client_id, ) @@ -2564,7 +2463,7 @@ async def oauth_client_authorize( if not registered: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to re-register OAuth client", + detail='Failed to re-register OAuth client', ) client = oauth_client_manager.get_client(client_id) @@ -2572,21 +2471,19 @@ async def oauth_client_authorize( if client is None or client_info is None: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="OAuth client unavailable after re-registration", + detail='OAuth client unavailable after re-registration', ) - if not await oauth_client_manager._preflight_authorization_url( - client, client_info - ): + if not await oauth_client_manager._preflight_authorization_url(client, client_info): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="OAuth client registration is still invalid after re-registration", + detail='OAuth client registration is still invalid after re-registration', ) return await oauth_client_manager.handle_authorize(request, client_id=client_id) -@app.get("/oauth/clients/{client_id}/callback") +@app.get('/oauth/clients/{client_id}/callback') async def oauth_client_callback( client_id: str, request: Request, @@ -2601,7 +2498,7 @@ async def oauth_client_callback( ) -@app.get("/oauth/{provider}/login") +@app.get('/oauth/{provider}/login') async def oauth_login(provider: str, request: Request): return await oauth_manager.handle_login(request, provider) @@ -2612,8 +2509,8 @@ async def oauth_login(provider: str, request: Request): # - This is considered insecure in general, as OAuth providers do not always verify email addresses # 3. If there is no user, and ENABLE_OAUTH_SIGNUP is true, create a user # - Email addresses are considered unique, so we fail registration if the email address is already taken -@app.get("/oauth/{provider}/login/callback") -@app.get("/oauth/{provider}/callback") # Legacy endpoint +@app.get('/oauth/{provider}/login/callback') +@app.get('/oauth/{provider}/callback') # Legacy endpoint async def oauth_login_callback( provider: str, request: Request, @@ -2623,41 +2520,41 @@ async def oauth_login_callback( return await oauth_manager.handle_callback(request, provider, response, db=db) -@app.get("/manifest.json") +@app.get('/manifest.json') async def get_manifest_json(): if app.state.EXTERNAL_PWA_MANIFEST_URL: return requests.get(app.state.EXTERNAL_PWA_MANIFEST_URL).json() else: return { - "name": app.state.WEBUI_NAME, - "short_name": app.state.WEBUI_NAME, - "description": f"{app.state.WEBUI_NAME} is an open, extensible, user-friendly interface for AI that adapts to your workflow.", - "start_url": "/", - "display": "standalone", - "background_color": "#343541", - "icons": [ + 'name': app.state.WEBUI_NAME, + 'short_name': app.state.WEBUI_NAME, + 'description': f'{app.state.WEBUI_NAME} is an open, extensible, user-friendly interface for AI that adapts to your workflow.', + 'start_url': '/', + 'display': 'standalone', + 'background_color': '#343541', + 'icons': [ { - "src": "/static/logo.png", - "type": "image/png", - "sizes": "500x500", - "purpose": "any", + 'src': '/static/logo.png', + 'type': 'image/png', + 'sizes': '500x500', + 'purpose': 'any', }, { - "src": "/static/logo.png", - "type": "image/png", - "sizes": "500x500", - "purpose": "maskable", + 'src': '/static/logo.png', + 'type': 'image/png', + 'sizes': '500x500', + 'purpose': 'maskable', }, ], - "share_target": { - "action": "/", - "method": "GET", - "params": {"text": "shared"}, + 'share_target': { + 'action': '/', + 'method': 'GET', + 'params': {'text': 'shared'}, }, } -@app.get("/opensearch.xml") +@app.get('/opensearch.xml') async def get_opensearch_xml(): xml_content = rf""" @@ -2665,28 +2562,69 @@ async def get_opensearch_xml(): Search {app.state.WEBUI_NAME} UTF-8 {app.state.config.WEBUI_URL}/static/favicon.png - + {app.state.config.WEBUI_URL} """ - return Response(content=xml_content, media_type="application/xml") + return Response(content=xml_content, media_type='application/xml') -@app.get("/health") +@app.get('/health') async def healthcheck(): - return {"status": True} + return {'status': True} + + +@app.get('/ready') +async def readiness_check(): + """ + Returns 200 only when the application is ready to accept traffic. + """ + + # Ensure application startup work has completed + if not getattr(app.state, 'startup_complete', False): + log.info('Readiness check failed: startup not complete') + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail='Startup not complete', + ) + + # Check database connectivity + try: + ScopedSession.execute(text('SELECT 1;')).all() + except Exception as e: + log.warning(f'Readiness check DB ping failed: {e!r}') + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail='Database not ready', + ) + + # Check Redis connectivity if configured + redis = app.state.redis + if redis is not None: + try: + pong = await redis.ping() + if pong is False: + raise Exception('Redis PING returned False') + except Exception as e: + log.warning(f'Readiness check Redis ping failed: {e!r}') + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail='Redis not ready', + ) + return {'status': True} -@app.get("/health/db") + +@app.get('/health/db') async def healthcheck_with_db(): - ScopedSession.execute(text("SELECT 1;")).all() - return {"status": True} + ScopedSession.execute(text('SELECT 1;')).all() + return {'status': True} -app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static') -@app.get("/cache/{path:path}") +@app.get('/cache/{path:path}') async def serve_cache_file( path: str, user=Depends(get_verified_user), @@ -2694,9 +2632,9 @@ async def serve_cache_file( file_path = os.path.abspath(os.path.join(CACHE_DIR, path)) # prevent path traversal if not file_path.startswith(os.path.abspath(CACHE_DIR)): - raise HTTPException(status_code=404, detail="File not found") + raise HTTPException(status_code=404, detail='File not found') if not os.path.isfile(file_path): - raise HTTPException(status_code=404, detail="File not found") + raise HTTPException(status_code=404, detail='File not found') return FileResponse(file_path) @@ -2704,22 +2642,20 @@ def swagger_ui_html(*args, **kwargs): return get_swagger_ui_html( *args, **kwargs, - swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js", - swagger_css_url="/static/swagger-ui/swagger-ui.css", - swagger_favicon_url="/static/swagger-ui/favicon.png", + swagger_js_url='/static/swagger-ui/swagger-ui-bundle.js', + swagger_css_url='/static/swagger-ui/swagger-ui.css', + swagger_favicon_url='/static/swagger-ui/favicon.png', ) applications.get_swagger_ui_html = swagger_ui_html if os.path.exists(FRONTEND_BUILD_DIR): - mimetypes.add_type("text/javascript", ".js") + mimetypes.add_type('text/javascript', '.js') app.mount( - "/", + '/', SPAStaticFiles(directory=FRONTEND_BUILD_DIR, html=True), - name="spa-static-files", + name='spa-static-files', ) else: - log.warning( - f"Frontend build directory not found at '{FRONTEND_BUILD_DIR}'. Serving API only." - ) + log.warning(f"Frontend build directory not found at '{FRONTEND_BUILD_DIR}'. Serving API only.") diff --git a/backend/open_webui/migrate.py b/backend/open_webui/migrate.py index 99fb39dfdb..1041dc78d2 100644 --- a/backend/open_webui/migrate.py +++ b/backend/open_webui/migrate.py @@ -5,20 +5,20 @@ # Function to run the alembic migrations def run_migrations(): - log.info("Running migrations") + log.info('Running migrations') try: from alembic import command from alembic.config import Config - alembic_cfg = Config(OPEN_WEBUI_DIR / "alembic.ini") + alembic_cfg = Config(OPEN_WEBUI_DIR / 'alembic.ini') # Set the script location dynamically - migrations_path = OPEN_WEBUI_DIR / "migrations" - alembic_cfg.set_main_option("script_location", str(migrations_path)) + migrations_path = OPEN_WEBUI_DIR / 'migrations' + alembic_cfg.set_main_option('script_location', str(migrations_path)) - command.upgrade(alembic_cfg, "head") + command.upgrade(alembic_cfg, 'head') except Exception as e: - log.exception(f"Error running migrations: {e}") + log.exception(f'Error running migrations: {e}') def run_extra_migrations(): @@ -26,53 +26,51 @@ def run_extra_migrations(): Only create table or index is allowed here. """ custom_migrations = [ - {"base": "3781e22d8b01", "upgrade_to": "1403e6d80d1d"}, - {"base": "d31026856c01", "upgrade_to": "97c08d196e3d"}, + {'base': '3781e22d8b01', 'upgrade_to': '1403e6d80d1d'}, + {'base': 'd31026856c01', 'upgrade_to': '97c08d196e3d'}, ] - log.info("Running extra migrations") + log.info('Running extra migrations') # do migrations try: # load version from db - current_version = ScopedSession.execute( - text("SELECT version_num FROM alembic_version") - ).scalar_one() + current_version = ScopedSession.execute(text('SELECT version_num FROM alembic_version')).scalar_one() # init alembic from alembic import command from alembic.config import Config - alembic_cfg = Config(OPEN_WEBUI_DIR / "alembic.ini") - migrations_path = OPEN_WEBUI_DIR / "migrations" - alembic_cfg.set_main_option("script_location", str(migrations_path)) + alembic_cfg = Config(OPEN_WEBUI_DIR / 'alembic.ini') + migrations_path = OPEN_WEBUI_DIR / 'migrations' + alembic_cfg.set_main_option('script_location', str(migrations_path)) # do migrations for migration in custom_migrations: try: - command.stamp(alembic_cfg, migration["base"]) - command.upgrade(alembic_cfg, migration["upgrade_to"]) + command.stamp(alembic_cfg, migration['base']) + command.upgrade(alembic_cfg, migration['upgrade_to']) except Exception as err: err = str(err) - if err.index("already exists") != -1 or err.index("duplicate") != -1: + if err.index('already exists') != -1 or err.index('duplicate') != -1: log.info( - "skip migrate %s to %s: already exists", - migration["base"], - migration["upgrade_to"], + 'skip migrate %s to %s: already exists', + migration['base'], + migration['upgrade_to'], ) continue log.warning( - "failed to migrate %s to %s: %s", - migration["base"], - migration["upgrade_to"], + 'failed to migrate %s to %s: %s', + migration['base'], + migration['upgrade_to'], err, ) # stamp to current version command.stamp(alembic_cfg, current_version) except Exception as e: - log.exception("Error running extra migrations: %s", e) + log.exception('Error running extra migrations: %s', e) -if __name__ == "__main__": +if __name__ == '__main__': if ENABLE_DB_MIGRATIONS: run_migrations() run_extra_migrations() diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 720b90f5fc..9ee6c2dceb 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -16,7 +16,7 @@ fileConfig(config.config_file_name, disable_existing_loggers=False) # Re-apply JSON formatter after fileConfig replaces handlers. -if LOG_FORMAT == "json": +if LOG_FORMAT == 'json': from open_webui.env import JSONFormatter for handler in logging.root.handlers: @@ -36,7 +36,7 @@ DB_URL = DATABASE_URL if DB_URL: - config.set_main_option("sqlalchemy.url", DB_URL.replace("%", "%%")) + config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%')) def run_migrations_offline() -> None: @@ -51,12 +51,12 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url") + url = config.get_main_option('sqlalchemy.url') context.configure( url=url, target_metadata=target_metadata, literal_binds=True, - dialect_opts={"paramstyle": "named"}, + dialect_opts={'paramstyle': 'named'}, ) with context.begin_transaction(): @@ -71,15 +71,13 @@ def run_migrations_online() -> None: """ # Handle SQLCipher URLs - if DB_URL and DB_URL.startswith("sqlite+sqlcipher://"): - if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == "": - raise ValueError( - "DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs" - ) + if DB_URL and DB_URL.startswith('sqlite+sqlcipher://'): + if not DATABASE_PASSWORD or DATABASE_PASSWORD.strip() == '': + raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs') # Extract database path from SQLCipher URL - db_path = DB_URL.replace("sqlite+sqlcipher://", "") - if db_path.startswith("/"): + db_path = DB_URL.replace('sqlite+sqlcipher://', '') + if db_path.startswith('/'): db_path = db_path[1:] # Remove leading slash for relative paths # Create a custom creator function that uses sqlcipher3 @@ -91,7 +89,7 @@ def create_sqlcipher_connection(): return conn connectable = create_engine( - "sqlite://", # Dummy URL since we're using creator + 'sqlite://', # Dummy URL since we're using creator creator=create_sqlcipher_connection, echo=False, ) @@ -99,7 +97,7 @@ def create_sqlcipher_connection(): # Standard database connection (existing logic) connectable = engine_from_config( config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", + prefix='sqlalchemy.', poolclass=pool.NullPool, ) diff --git a/backend/open_webui/migrations/util.py b/backend/open_webui/migrations/util.py index 955066602a..6ea2a5f4bb 100644 --- a/backend/open_webui/migrations/util.py +++ b/backend/open_webui/migrations/util.py @@ -12,4 +12,4 @@ def get_existing_tables(): def get_revision_id(): import uuid - return str(uuid.uuid4()).replace("-", "")[:12] + return str(uuid.uuid4()).replace('-', '')[:12] diff --git a/backend/open_webui/migrations/versions/018012973d35_add_indexes.py b/backend/open_webui/migrations/versions/018012973d35_add_indexes.py index 3c6355d9a4..5a7c8fca4a 100644 --- a/backend/open_webui/migrations/versions/018012973d35_add_indexes.py +++ b/backend/open_webui/migrations/versions/018012973d35_add_indexes.py @@ -9,38 +9,38 @@ from alembic import op import sqlalchemy as sa -revision = "018012973d35" -down_revision = "97c08d196e3d" +revision = '018012973d35' +down_revision = '97c08d196e3d' branch_labels = None depends_on = None def upgrade(): # Chat table indexes - op.create_index("folder_id_idx", "chat", ["folder_id"]) - op.create_index("user_id_pinned_idx", "chat", ["user_id", "pinned"]) - op.create_index("user_id_archived_idx", "chat", ["user_id", "archived"]) - op.create_index("updated_at_user_id_idx", "chat", ["updated_at", "user_id"]) - op.create_index("folder_id_user_id_idx", "chat", ["folder_id", "user_id"]) + op.create_index('folder_id_idx', 'chat', ['folder_id']) + op.create_index('user_id_pinned_idx', 'chat', ['user_id', 'pinned']) + op.create_index('user_id_archived_idx', 'chat', ['user_id', 'archived']) + op.create_index('updated_at_user_id_idx', 'chat', ['updated_at', 'user_id']) + op.create_index('folder_id_user_id_idx', 'chat', ['folder_id', 'user_id']) # Tag table index - op.create_index("user_id_idx", "tag", ["user_id"]) + op.create_index('user_id_idx', 'tag', ['user_id']) # Function table index - op.create_index("is_global_idx", "function", ["is_global"]) + op.create_index('is_global_idx', 'function', ['is_global']) def downgrade(): # Chat table indexes - op.drop_index("folder_id_idx", table_name="chat") - op.drop_index("user_id_pinned_idx", table_name="chat") - op.drop_index("user_id_archived_idx", table_name="chat") - op.drop_index("updated_at_user_id_idx", table_name="chat") - op.drop_index("folder_id_user_id_idx", table_name="chat") + op.drop_index('folder_id_idx', table_name='chat') + op.drop_index('user_id_pinned_idx', table_name='chat') + op.drop_index('user_id_archived_idx', table_name='chat') + op.drop_index('updated_at_user_id_idx', table_name='chat') + op.drop_index('folder_id_user_id_idx', table_name='chat') # Tag table index - op.drop_index("user_id_idx", table_name="tag") + op.drop_index('user_id_idx', table_name='tag') # Function table index - op.drop_index("is_global_idx", table_name="function") + op.drop_index('is_global_idx', table_name='function') diff --git a/backend/open_webui/migrations/versions/1403e6d80d1d_add_index_to_trade_log.py b/backend/open_webui/migrations/versions/1403e6d80d1d_add_index_to_trade_log.py index 4eb13f3f68..330c1ae1fa 100644 --- a/backend/open_webui/migrations/versions/1403e6d80d1d_add_index_to_trade_log.py +++ b/backend/open_webui/migrations/versions/1403e6d80d1d_add_index_to_trade_log.py @@ -14,21 +14,19 @@ from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision: str = "1403e6d80d1d" -down_revision: Union[str, None] = "a7dd10d9b220" +revision: str = '1403e6d80d1d' +down_revision: Union[str, None] = 'a7dd10d9b220' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_index( - op.f("ix_trade_ticket_created_at"), "trade_ticket", ["created_at"], unique=False - ) + op.create_index(op.f('ix_trade_ticket_created_at'), 'trade_ticket', ['created_at'], unique=False) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f("ix_trade_ticket_created_at"), table_name="trade_ticket") + op.drop_index(op.f('ix_trade_ticket_created_at'), table_name='trade_ticket') # ### end Alembic commands ### diff --git a/backend/open_webui/migrations/versions/1af9b942657b_migrate_tags.py b/backend/open_webui/migrations/versions/1af9b942657b_migrate_tags.py index 8a0ab1b491..caffb7e3b4 100644 --- a/backend/open_webui/migrations/versions/1af9b942657b_migrate_tags.py +++ b/backend/open_webui/migrations/versions/1af9b942657b_migrate_tags.py @@ -13,8 +13,8 @@ import json -revision = "1af9b942657b" -down_revision = "242a2047eae0" +revision = '1af9b942657b' +down_revision = '242a2047eae0' branch_labels = None depends_on = None @@ -25,43 +25,40 @@ def upgrade(): inspector = Inspector.from_engine(conn) # Clean up potential leftover temp table from previous failures - conn.execute(sa.text("DROP TABLE IF EXISTS _alembic_tmp_tag")) + conn.execute(sa.text('DROP TABLE IF EXISTS _alembic_tmp_tag')) # Check if the 'tag' table exists tables = inspector.get_table_names() # Step 1: Modify Tag table using batch mode for SQLite support - if "tag" in tables: + if 'tag' in tables: # Get the current columns in the 'tag' table - columns = [col["name"] for col in inspector.get_columns("tag")] + columns = [col['name'] for col in inspector.get_columns('tag')] # Get any existing unique constraints on the 'tag' table - current_constraints = inspector.get_unique_constraints("tag") + current_constraints = inspector.get_unique_constraints('tag') - with op.batch_alter_table("tag", schema=None) as batch_op: + with op.batch_alter_table('tag', schema=None) as batch_op: # Check if the unique constraint already exists - if not any( - constraint["name"] == "uq_id_user_id" - for constraint in current_constraints - ): + if not any(constraint['name'] == 'uq_id_user_id' for constraint in current_constraints): # Create unique constraint if it doesn't exist - batch_op.create_unique_constraint("uq_id_user_id", ["id", "user_id"]) + batch_op.create_unique_constraint('uq_id_user_id', ['id', 'user_id']) # Check if the 'data' column exists before trying to drop it - if "data" in columns: - batch_op.drop_column("data") + if 'data' in columns: + batch_op.drop_column('data') # Check if the 'meta' column needs to be created - if "meta" not in columns: + if 'meta' not in columns: # Add the 'meta' column if it doesn't already exist - batch_op.add_column(sa.Column("meta", sa.JSON(), nullable=True)) + batch_op.add_column(sa.Column('meta', sa.JSON(), nullable=True)) tag = table( - "tag", - column("id", sa.String()), - column("name", sa.String()), - column("user_id", sa.String()), - column("meta", sa.JSON()), + 'tag', + column('id', sa.String()), + column('name', sa.String()), + column('user_id', sa.String()), + column('meta', sa.JSON()), ) # Step 2: Migrate tags @@ -70,12 +67,12 @@ def upgrade(): tag_updates = {} for row in result: - new_id = row.name.replace(" ", "_").lower() + new_id = row.name.replace(' ', '_').lower() tag_updates[row.id] = new_id for tag_id, new_tag_id in tag_updates.items(): - print(f"Updating tag {tag_id} to {new_tag_id}") - if new_tag_id == "pinned": + print(f'Updating tag {tag_id} to {new_tag_id}') + if new_tag_id == 'pinned': # delete tag delete_stmt = sa.delete(tag).where(tag.c.id == tag_id) conn.execute(delete_stmt) @@ -86,9 +83,7 @@ def upgrade(): if existing_tag_result: # Handle duplicate case: the new_tag_id already exists - print( - f"Tag {new_tag_id} already exists. Removing current tag with ID {tag_id} to avoid duplicates." - ) + print(f'Tag {new_tag_id} already exists. Removing current tag with ID {tag_id} to avoid duplicates.') # Option 1: Delete the current tag if an update to new_tag_id would cause duplication delete_stmt = sa.delete(tag).where(tag.c.id == tag_id) conn.execute(delete_stmt) @@ -98,19 +93,15 @@ def upgrade(): conn.execute(update_stmt) # Add columns `pinned` and `meta` to 'chat' - op.add_column("chat", sa.Column("pinned", sa.Boolean(), nullable=True)) - op.add_column( - "chat", sa.Column("meta", sa.JSON(), nullable=False, server_default="{}") - ) + op.add_column('chat', sa.Column('pinned', sa.Boolean(), nullable=True)) + op.add_column('chat', sa.Column('meta', sa.JSON(), nullable=False, server_default='{}')) - chatidtag = table( - "chatidtag", column("chat_id", sa.String()), column("tag_name", sa.String()) - ) + chatidtag = table('chatidtag', column('chat_id', sa.String()), column('tag_name', sa.String())) chat = table( - "chat", - column("id", sa.String()), - column("pinned", sa.Boolean()), - column("meta", sa.JSON()), + 'chat', + column('id', sa.String()), + column('pinned', sa.Boolean()), + column('meta', sa.JSON()), ) # Fetch existing tags @@ -120,29 +111,27 @@ def upgrade(): chat_updates = {} for row in result: chat_id = row.chat_id - tag_name = row.tag_name.replace(" ", "_").lower() + tag_name = row.tag_name.replace(' ', '_').lower() - if tag_name == "pinned": + if tag_name == 'pinned': # Specifically handle 'pinned' tag if chat_id not in chat_updates: - chat_updates[chat_id] = {"pinned": True, "meta": {}} + chat_updates[chat_id] = {'pinned': True, 'meta': {}} else: - chat_updates[chat_id]["pinned"] = True + chat_updates[chat_id]['pinned'] = True else: if chat_id not in chat_updates: - chat_updates[chat_id] = {"pinned": False, "meta": {"tags": [tag_name]}} + chat_updates[chat_id] = {'pinned': False, 'meta': {'tags': [tag_name]}} else: - tags = chat_updates[chat_id]["meta"].get("tags", []) + tags = chat_updates[chat_id]['meta'].get('tags', []) tags.append(tag_name) - chat_updates[chat_id]["meta"]["tags"] = list(set(tags)) + chat_updates[chat_id]['meta']['tags'] = list(set(tags)) # Update chats based on accumulated changes for chat_id, updates in chat_updates.items(): update_stmt = sa.update(chat).where(chat.c.id == chat_id) - update_stmt = update_stmt.values( - meta=updates.get("meta", {}), pinned=updates.get("pinned", False) - ) + update_stmt = update_stmt.values(meta=updates.get('meta', {}), pinned=updates.get('pinned', False)) conn.execute(update_stmt) pass diff --git a/backend/open_webui/migrations/versions/242a2047eae0_update_chat_table.py b/backend/open_webui/migrations/versions/242a2047eae0_update_chat_table.py index 6017da3169..7fadb05a92 100644 --- a/backend/open_webui/migrations/versions/242a2047eae0_update_chat_table.py +++ b/backend/open_webui/migrations/versions/242a2047eae0_update_chat_table.py @@ -12,8 +12,8 @@ import json -revision = "242a2047eae0" -down_revision = "6a39f3d8e55c" +revision = '242a2047eae0' +down_revision = '6a39f3d8e55c' branch_labels = None depends_on = None @@ -22,39 +22,37 @@ def upgrade(): conn = op.get_bind() inspector = sa.inspect(conn) - columns = inspector.get_columns("chat") - column_dict = {col["name"]: col for col in columns} + columns = inspector.get_columns('chat') + column_dict = {col['name']: col for col in columns} - chat_column = column_dict.get("chat") - old_chat_exists = "old_chat" in column_dict + chat_column = column_dict.get('chat') + old_chat_exists = 'old_chat' in column_dict if chat_column: - if isinstance(chat_column["type"], sa.Text): + if isinstance(chat_column['type'], sa.Text): print("Converting 'chat' column to JSON") if old_chat_exists: print("Dropping old 'old_chat' column") - op.drop_column("chat", "old_chat") + op.drop_column('chat', 'old_chat') # Step 1: Rename current 'chat' column to 'old_chat' print("Renaming 'chat' column to 'old_chat'") - op.alter_column( - "chat", "chat", new_column_name="old_chat", existing_type=sa.Text() - ) + op.alter_column('chat', 'chat', new_column_name='old_chat', existing_type=sa.Text()) # Step 2: Add new 'chat' column of type JSON print("Adding new 'chat' column of type JSON") - op.add_column("chat", sa.Column("chat", sa.JSON(), nullable=True)) + op.add_column('chat', sa.Column('chat', sa.JSON(), nullable=True)) else: # If the column is already JSON, no need to do anything pass # Step 3: Migrate data from 'old_chat' to 'chat' chat_table = table( - "chat", - sa.Column("id", sa.String(), primary_key=True), - sa.Column("old_chat", sa.Text()), - sa.Column("chat", sa.JSON()), + 'chat', + sa.Column('id', sa.String(), primary_key=True), + sa.Column('old_chat', sa.Text()), + sa.Column('chat', sa.JSON()), ) # - Selecting all data from the table @@ -67,41 +65,33 @@ def upgrade(): except json.JSONDecodeError: json_data = None # Handle cases where the text cannot be converted to JSON - connection.execute( - sa.update(chat_table) - .where(chat_table.c.id == row.id) - .values(chat=json_data) - ) + connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(chat=json_data)) # Step 4: Drop 'old_chat' column print("Dropping 'old_chat' column") - op.drop_column("chat", "old_chat") + op.drop_column('chat', 'old_chat') def downgrade(): # Step 1: Add 'old_chat' column back as Text - op.add_column("chat", sa.Column("old_chat", sa.Text(), nullable=True)) + op.add_column('chat', sa.Column('old_chat', sa.Text(), nullable=True)) # Step 2: Convert 'chat' JSON data back to text and store in 'old_chat' chat_table = table( - "chat", - sa.Column("id", sa.String(), primary_key=True), - sa.Column("chat", sa.JSON()), - sa.Column("old_chat", sa.Text()), + 'chat', + sa.Column('id', sa.String(), primary_key=True), + sa.Column('chat', sa.JSON()), + sa.Column('old_chat', sa.Text()), ) connection = op.get_bind() results = connection.execute(select(chat_table.c.id, chat_table.c.chat)) for row in results: text_data = json.dumps(row.chat) if row.chat is not None else None - connection.execute( - sa.update(chat_table) - .where(chat_table.c.id == row.id) - .values(old_chat=text_data) - ) + connection.execute(sa.update(chat_table).where(chat_table.c.id == row.id).values(old_chat=text_data)) # Step 3: Remove the new 'chat' JSON column - op.drop_column("chat", "chat") + op.drop_column('chat', 'chat') # Step 4: Rename 'old_chat' back to 'chat' - op.alter_column("chat", "old_chat", new_column_name="chat", existing_type=sa.Text()) + op.alter_column('chat', 'old_chat', new_column_name='chat', existing_type=sa.Text()) diff --git a/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py b/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py index 1a4ae73180..51a8e329f1 100644 --- a/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py +++ b/backend/open_webui/migrations/versions/2f1211949ecc_update_message_and_channel_member_table.py @@ -13,19 +13,19 @@ import open_webui.internal.db # revision identifiers, used by Alembic. -revision: str = "2f1211949ecc" -down_revision: Union[str, None] = "37f288994c47" +revision: str = '2f1211949ecc' +down_revision: Union[str, None] = '37f288994c47' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # New columns to be added to channel_member table - op.add_column("channel_member", sa.Column("status", sa.Text(), nullable=True)) + op.add_column('channel_member', sa.Column('status', sa.Text(), nullable=True)) op.add_column( - "channel_member", + 'channel_member', sa.Column( - "is_active", + 'is_active', sa.Boolean(), nullable=False, default=True, @@ -34,9 +34,9 @@ def upgrade() -> None: ) op.add_column( - "channel_member", + 'channel_member', sa.Column( - "is_channel_muted", + 'is_channel_muted', sa.Boolean(), nullable=False, default=False, @@ -44,9 +44,9 @@ def upgrade() -> None: ), ) op.add_column( - "channel_member", + 'channel_member', sa.Column( - "is_channel_pinned", + 'is_channel_pinned', sa.Boolean(), nullable=False, default=False, @@ -54,49 +54,41 @@ def upgrade() -> None: ), ) - op.add_column("channel_member", sa.Column("data", sa.JSON(), nullable=True)) - op.add_column("channel_member", sa.Column("meta", sa.JSON(), nullable=True)) + op.add_column('channel_member', sa.Column('data', sa.JSON(), nullable=True)) + op.add_column('channel_member', sa.Column('meta', sa.JSON(), nullable=True)) - op.add_column( - "channel_member", sa.Column("joined_at", sa.BigInteger(), nullable=False) - ) - op.add_column( - "channel_member", sa.Column("left_at", sa.BigInteger(), nullable=True) - ) + op.add_column('channel_member', sa.Column('joined_at', sa.BigInteger(), nullable=False)) + op.add_column('channel_member', sa.Column('left_at', sa.BigInteger(), nullable=True)) - op.add_column( - "channel_member", sa.Column("last_read_at", sa.BigInteger(), nullable=True) - ) + op.add_column('channel_member', sa.Column('last_read_at', sa.BigInteger(), nullable=True)) - op.add_column( - "channel_member", sa.Column("updated_at", sa.BigInteger(), nullable=True) - ) + op.add_column('channel_member', sa.Column('updated_at', sa.BigInteger(), nullable=True)) # New columns to be added to message table op.add_column( - "message", + 'message', sa.Column( - "is_pinned", + 'is_pinned', sa.Boolean(), nullable=False, default=False, server_default=sa.sql.expression.false(), ), ) - op.add_column("message", sa.Column("pinned_at", sa.BigInteger(), nullable=True)) - op.add_column("message", sa.Column("pinned_by", sa.Text(), nullable=True)) + op.add_column('message', sa.Column('pinned_at', sa.BigInteger(), nullable=True)) + op.add_column('message', sa.Column('pinned_by', sa.Text(), nullable=True)) def downgrade() -> None: - op.drop_column("channel_member", "updated_at") - op.drop_column("channel_member", "last_read_at") + op.drop_column('channel_member', 'updated_at') + op.drop_column('channel_member', 'last_read_at') - op.drop_column("channel_member", "meta") - op.drop_column("channel_member", "data") + op.drop_column('channel_member', 'meta') + op.drop_column('channel_member', 'data') - op.drop_column("channel_member", "is_channel_pinned") - op.drop_column("channel_member", "is_channel_muted") + op.drop_column('channel_member', 'is_channel_pinned') + op.drop_column('channel_member', 'is_channel_muted') - op.drop_column("message", "pinned_by") - op.drop_column("message", "pinned_at") - op.drop_column("message", "is_pinned") + op.drop_column('message', 'pinned_by') + op.drop_column('message', 'pinned_at') + op.drop_column('message', 'is_pinned') diff --git a/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py index 57bc8748e3..c412107032 100644 --- a/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py +++ b/backend/open_webui/migrations/versions/374d2f66af06_add_prompt_history_table.py @@ -12,8 +12,8 @@ from alembic import op import sqlalchemy as sa -revision: str = "374d2f66af06" -down_revision: Union[str, None] = "c440947495f3" +revision: str = '374d2f66af06' +down_revision: Union[str, None] = 'c440947495f3' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -26,13 +26,13 @@ def upgrade() -> None: # We need to assume the OLD structure. old_prompt_table = sa.table( - "prompt", - sa.column("command", sa.Text()), - sa.column("user_id", sa.Text()), - sa.column("title", sa.Text()), - sa.column("content", sa.Text()), - sa.column("timestamp", sa.BigInteger()), - sa.column("access_control", sa.JSON()), + 'prompt', + sa.column('command', sa.Text()), + sa.column('user_id', sa.Text()), + sa.column('title', sa.Text()), + sa.column('content', sa.Text()), + sa.column('timestamp', sa.BigInteger()), + sa.column('access_control', sa.JSON()), ) # Check if table exists/read data @@ -53,61 +53,61 @@ def upgrade() -> None: # Step 2: Create new prompt table with 'id' as PRIMARY KEY op.create_table( - "prompt_new", - sa.Column("id", sa.Text(), primary_key=True), - sa.Column("command", sa.String(), unique=True, index=True), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("data", sa.JSON(), nullable=True), - sa.Column("meta", sa.JSON(), nullable=True), - sa.Column("access_control", sa.JSON(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), - sa.Column("version_id", sa.Text(), nullable=True), - sa.Column("tags", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=False), + 'prompt_new', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('command', sa.String(), unique=True, index=True), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('access_control', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'), + sa.Column('version_id', sa.Text(), nullable=True), + sa.Column('tags', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), ) # Step 3: Create prompt_history table op.create_table( - "prompt_history", - sa.Column("id", sa.Text(), primary_key=True), - sa.Column("prompt_id", sa.Text(), nullable=False, index=True), - sa.Column("parent_id", sa.Text(), nullable=True), - sa.Column("snapshot", sa.JSON(), nullable=False), - sa.Column("user_id", sa.Text(), nullable=False), - sa.Column("commit_message", sa.Text(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=False), + 'prompt_history', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('prompt_id', sa.Text(), nullable=False, index=True), + sa.Column('parent_id', sa.Text(), nullable=True), + sa.Column('snapshot', sa.JSON(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('commit_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), ) # Step 4: Migrate data prompt_new_table = sa.table( - "prompt_new", - sa.column("id", sa.Text()), - sa.column("command", sa.String()), - sa.column("user_id", sa.String()), - sa.column("name", sa.Text()), - sa.column("content", sa.Text()), - sa.column("data", sa.JSON()), - sa.column("meta", sa.JSON()), - sa.column("access_control", sa.JSON()), - sa.column("is_active", sa.Boolean()), - sa.column("version_id", sa.Text()), - sa.column("tags", sa.JSON()), - sa.column("created_at", sa.BigInteger()), - sa.column("updated_at", sa.BigInteger()), + 'prompt_new', + sa.column('id', sa.Text()), + sa.column('command', sa.String()), + sa.column('user_id', sa.String()), + sa.column('name', sa.Text()), + sa.column('content', sa.Text()), + sa.column('data', sa.JSON()), + sa.column('meta', sa.JSON()), + sa.column('access_control', sa.JSON()), + sa.column('is_active', sa.Boolean()), + sa.column('version_id', sa.Text()), + sa.column('tags', sa.JSON()), + sa.column('created_at', sa.BigInteger()), + sa.column('updated_at', sa.BigInteger()), ) prompt_history_table = sa.table( - "prompt_history", - sa.column("id", sa.Text()), - sa.column("prompt_id", sa.Text()), - sa.column("parent_id", sa.Text()), - sa.column("snapshot", sa.JSON()), - sa.column("user_id", sa.Text()), - sa.column("commit_message", sa.Text()), - sa.column("created_at", sa.BigInteger()), + 'prompt_history', + sa.column('id', sa.Text()), + sa.column('prompt_id', sa.Text()), + sa.column('parent_id', sa.Text()), + sa.column('snapshot', sa.JSON()), + sa.column('user_id', sa.Text()), + sa.column('commit_message', sa.Text()), + sa.column('created_at', sa.BigInteger()), ) for row in existing_prompts: @@ -120,7 +120,7 @@ def upgrade() -> None: new_uuid = str(uuid.uuid4()) history_uuid = str(uuid.uuid4()) - clean_command = command[1:] if command and command.startswith("/") else command + clean_command = command[1:] if command and command.startswith('/') else command # Insert into prompt_new conn.execute( @@ -148,12 +148,12 @@ def upgrade() -> None: prompt_id=new_uuid, parent_id=None, snapshot={ - "name": title, - "content": content, - "command": clean_command, - "data": {}, - "meta": {}, - "access_control": access_control, + 'name': title, + 'content': content, + 'command': clean_command, + 'data': {}, + 'meta': {}, + 'access_control': access_control, }, user_id=user_id, commit_message=None, @@ -162,8 +162,8 @@ def upgrade() -> None: ) # Step 5: Replace old table with new one - op.drop_table("prompt") - op.rename_table("prompt_new", "prompt") + op.drop_table('prompt') + op.rename_table('prompt_new', 'prompt') def downgrade() -> None: @@ -171,13 +171,13 @@ def downgrade() -> None: # Step 1: Read new data prompt_table = sa.table( - "prompt", - sa.column("command", sa.String()), - sa.column("name", sa.Text()), - sa.column("created_at", sa.BigInteger()), - sa.column("user_id", sa.Text()), - sa.column("content", sa.Text()), - sa.column("access_control", sa.JSON()), + 'prompt', + sa.column('command', sa.String()), + sa.column('name', sa.Text()), + sa.column('created_at', sa.BigInteger()), + sa.column('user_id', sa.Text()), + sa.column('content', sa.Text()), + sa.column('access_control', sa.JSON()), ) try: @@ -195,31 +195,31 @@ def downgrade() -> None: current_data = [] # Step 2: Drop history and table - op.drop_table("prompt_history") - op.drop_table("prompt") + op.drop_table('prompt_history') + op.drop_table('prompt') # Step 3: Recreate old table (command as PK?) # Assuming old schema: op.create_table( - "prompt", - sa.Column("command", sa.String(), primary_key=True), - sa.Column("user_id", sa.String()), - sa.Column("title", sa.Text()), - sa.Column("content", sa.Text()), - sa.Column("timestamp", sa.BigInteger()), - sa.Column("access_control", sa.JSON()), - sa.Column("id", sa.Integer(), nullable=True), + 'prompt', + sa.Column('command', sa.String(), primary_key=True), + sa.Column('user_id', sa.String()), + sa.Column('title', sa.Text()), + sa.Column('content', sa.Text()), + sa.Column('timestamp', sa.BigInteger()), + sa.Column('access_control', sa.JSON()), + sa.Column('id', sa.Integer(), nullable=True), ) # Step 4: Restore data old_prompt_table = sa.table( - "prompt", - sa.column("command", sa.String()), - sa.column("user_id", sa.String()), - sa.column("title", sa.Text()), - sa.column("content", sa.Text()), - sa.column("timestamp", sa.BigInteger()), - sa.column("access_control", sa.JSON()), + 'prompt', + sa.column('command', sa.String()), + sa.column('user_id', sa.String()), + sa.column('title', sa.Text()), + sa.column('content', sa.Text()), + sa.column('timestamp', sa.BigInteger()), + sa.column('access_control', sa.JSON()), ) for row in current_data: @@ -231,9 +231,7 @@ def downgrade() -> None: access_control = row[5] # Restore leading / - old_command = ( - "/" + command if command and not command.startswith("/") else command - ) + old_command = '/' + command if command and not command.startswith('/') else command conn.execute( sa.insert(old_prompt_table).values( diff --git a/backend/open_webui/migrations/versions/3781e22d8b01_update_message_table.py b/backend/open_webui/migrations/versions/3781e22d8b01_update_message_table.py index 16fb0e85eb..170137f23c 100644 --- a/backend/open_webui/migrations/versions/3781e22d8b01_update_message_table.py +++ b/backend/open_webui/migrations/versions/3781e22d8b01_update_message_table.py @@ -9,8 +9,8 @@ from alembic import op import sqlalchemy as sa -revision = "3781e22d8b01" -down_revision = "7826ab40b532" +revision = '3781e22d8b01' +down_revision = '7826ab40b532' branch_labels = None depends_on = None @@ -18,9 +18,9 @@ def upgrade(): # Add 'type' column to the 'channel' table op.add_column( - "channel", + 'channel', sa.Column( - "type", + 'type', sa.Text(), nullable=True, ), @@ -28,43 +28,31 @@ def upgrade(): # Add 'parent_id' column to the 'message' table for threads op.add_column( - "message", - sa.Column("parent_id", sa.Text(), nullable=True), + 'message', + sa.Column('parent_id', sa.Text(), nullable=True), ) op.create_table( - "message_reaction", - sa.Column( - "id", sa.Text(), nullable=False, primary_key=True, unique=True - ), # Unique reaction ID - sa.Column("user_id", sa.Text(), nullable=False), # User who reacted - sa.Column( - "message_id", sa.Text(), nullable=False - ), # Message that was reacted to - sa.Column( - "name", sa.Text(), nullable=False - ), # Reaction name (e.g. "thumbs_up") - sa.Column( - "created_at", sa.BigInteger(), nullable=True - ), # Timestamp of when the reaction was added + 'message_reaction', + sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Unique reaction ID + sa.Column('user_id', sa.Text(), nullable=False), # User who reacted + sa.Column('message_id', sa.Text(), nullable=False), # Message that was reacted to + sa.Column('name', sa.Text(), nullable=False), # Reaction name (e.g. "thumbs_up") + sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the reaction was added ) op.create_table( - "channel_member", - sa.Column( - "id", sa.Text(), nullable=False, primary_key=True, unique=True - ), # Record ID for the membership row - sa.Column("channel_id", sa.Text(), nullable=False), # Associated channel - sa.Column("user_id", sa.Text(), nullable=False), # Associated user - sa.Column( - "created_at", sa.BigInteger(), nullable=True - ), # Timestamp of when the user joined the channel + 'channel_member', + sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), # Record ID for the membership row + sa.Column('channel_id', sa.Text(), nullable=False), # Associated channel + sa.Column('user_id', sa.Text(), nullable=False), # Associated user + sa.Column('created_at', sa.BigInteger(), nullable=True), # Timestamp of when the user joined the channel ) def downgrade(): # Revert 'type' column addition to the 'channel' table - op.drop_column("channel", "type") - op.drop_column("message", "parent_id") - op.drop_table("message_reaction") - op.drop_table("channel_member") + op.drop_column('channel', 'type') + op.drop_column('message', 'parent_id') + op.drop_table('message_reaction') + op.drop_table('channel_member') diff --git a/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py b/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py index 229bb8cffb..4bf24d3b46 100644 --- a/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py +++ b/backend/open_webui/migrations/versions/37f288994c47_add_group_member_table.py @@ -15,8 +15,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = "37f288994c47" -down_revision: Union[str, None] = "a5c220713937" +revision: str = '37f288994c47' +down_revision: Union[str, None] = 'a5c220713937' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -24,50 +24,48 @@ def upgrade() -> None: # 1. Create new table op.create_table( - "group_member", - sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False), + 'group_member', + sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False), sa.Column( - "group_id", + 'group_id', sa.Text(), - sa.ForeignKey("group.id", ondelete="CASCADE"), + sa.ForeignKey('group.id', ondelete='CASCADE'), nullable=False, ), sa.Column( - "user_id", + 'user_id', sa.Text(), - sa.ForeignKey("user.id", ondelete="CASCADE"), + sa.ForeignKey('user.id', ondelete='CASCADE'), nullable=False, ), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), - sa.UniqueConstraint("group_id", "user_id", name="uq_group_member_group_user"), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + sa.UniqueConstraint('group_id', 'user_id', name='uq_group_member_group_user'), ) connection = op.get_bind() # 2. Read existing group with user_ids JSON column group_table = sa.Table( - "group", + 'group', sa.MetaData(), - sa.Column("id", sa.Text()), - sa.Column("user_ids", sa.JSON()), # JSON stored as text in SQLite + PG + sa.Column('id', sa.Text()), + sa.Column('user_ids', sa.JSON()), # JSON stored as text in SQLite + PG ) - results = connection.execute( - sa.select(group_table.c.id, group_table.c.user_ids) - ).fetchall() + results = connection.execute(sa.select(group_table.c.id, group_table.c.user_ids)).fetchall() print(results) # 3. Insert members into group_member table gm_table = sa.Table( - "group_member", + 'group_member', sa.MetaData(), - sa.Column("id", sa.Text()), - sa.Column("group_id", sa.Text()), - sa.Column("user_id", sa.Text()), - sa.Column("created_at", sa.BigInteger()), - sa.Column("updated_at", sa.BigInteger()), + sa.Column('id', sa.Text()), + sa.Column('group_id', sa.Text()), + sa.Column('user_id', sa.Text()), + sa.Column('created_at', sa.BigInteger()), + sa.Column('updated_at', sa.BigInteger()), ) now = int(time.time()) @@ -86,11 +84,11 @@ def upgrade() -> None: rows = [ { - "id": str(uuid.uuid4()), - "group_id": group_id, - "user_id": uid, - "created_at": now, - "updated_at": now, + 'id': str(uuid.uuid4()), + 'group_id': group_id, + 'user_id': uid, + 'created_at': now, + 'updated_at': now, } for uid in user_ids ] @@ -99,47 +97,41 @@ def upgrade() -> None: connection.execute(gm_table.insert(), rows) # 4. Optionally drop the old column - with op.batch_alter_table("group") as batch: - batch.drop_column("user_ids") + with op.batch_alter_table('group') as batch: + batch.drop_column('user_ids') def downgrade(): # Reverse: restore user_ids column - with op.batch_alter_table("group") as batch: - batch.add_column(sa.Column("user_ids", sa.JSON())) + with op.batch_alter_table('group') as batch: + batch.add_column(sa.Column('user_ids', sa.JSON())) connection = op.get_bind() gm_table = sa.Table( - "group_member", + 'group_member', sa.MetaData(), - sa.Column("group_id", sa.Text()), - sa.Column("user_id", sa.Text()), - sa.Column("created_at", sa.BigInteger()), - sa.Column("updated_at", sa.BigInteger()), + sa.Column('group_id', sa.Text()), + sa.Column('user_id', sa.Text()), + sa.Column('created_at', sa.BigInteger()), + sa.Column('updated_at', sa.BigInteger()), ) group_table = sa.Table( - "group", + 'group', sa.MetaData(), - sa.Column("id", sa.Text()), - sa.Column("user_ids", sa.JSON()), + sa.Column('id', sa.Text()), + sa.Column('user_ids', sa.JSON()), ) # Build JSON arrays again results = connection.execute(sa.select(group_table.c.id)).fetchall() for (group_id,) in results: - members = connection.execute( - sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id) - ).fetchall() + members = connection.execute(sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)).fetchall() member_ids = [m[0] for m in members] - connection.execute( - group_table.update() - .where(group_table.c.id == group_id) - .values(user_ids=member_ids) - ) + connection.execute(group_table.update().where(group_table.c.id == group_id).values(user_ids=member_ids)) # Drop the new table - op.drop_table("group_member") + op.drop_table('group_member') diff --git a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py index af8340a3cb..d415f500f3 100644 --- a/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py +++ b/backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py @@ -12,8 +12,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = "38d63c18f30f" -down_revision: Union[str, None] = "3af16a1c9fb6" +revision: str = '38d63c18f30f' +down_revision: Union[str, None] = '3af16a1c9fb6' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,59 +21,55 @@ def upgrade() -> None: # Ensure 'id' column in 'user' table is unique and primary key (ForeignKey constraint) inspector = sa.inspect(op.get_bind()) - columns = inspector.get_columns("user") + columns = inspector.get_columns('user') - pk_columns = inspector.get_pk_constraint("user")["constrained_columns"] - id_column = next((col for col in columns if col["name"] == "id"), None) + pk_columns = inspector.get_pk_constraint('user')['constrained_columns'] + id_column = next((col for col in columns if col['name'] == 'id'), None) - if id_column and not id_column.get("unique", False): - unique_constraints = inspector.get_unique_constraints("user") - unique_columns = {tuple(u["column_names"]) for u in unique_constraints} + if id_column and not id_column.get('unique', False): + unique_constraints = inspector.get_unique_constraints('user') + unique_columns = {tuple(u['column_names']) for u in unique_constraints} - with op.batch_alter_table("user") as batch_op: + with op.batch_alter_table('user') as batch_op: # If primary key is wrong, drop it - if pk_columns and pk_columns != ["id"]: - batch_op.drop_constraint( - inspector.get_pk_constraint("user")["name"], type_="primary" - ) + if pk_columns and pk_columns != ['id']: + batch_op.drop_constraint(inspector.get_pk_constraint('user')['name'], type_='primary') # Add unique constraint if missing - if ("id",) not in unique_columns: - batch_op.create_unique_constraint("uq_user_id", ["id"]) + if ('id',) not in unique_columns: + batch_op.create_unique_constraint('uq_user_id', ['id']) # Re-create correct primary key - batch_op.create_primary_key("pk_user_id", ["id"]) + batch_op.create_primary_key('pk_user_id', ['id']) # Create oauth_session table op.create_table( - "oauth_session", - sa.Column("id", sa.Text(), primary_key=True, nullable=False, unique=True), + 'oauth_session', + sa.Column('id', sa.Text(), primary_key=True, nullable=False, unique=True), sa.Column( - "user_id", + 'user_id', sa.Text(), - sa.ForeignKey("user.id", ondelete="CASCADE"), + sa.ForeignKey('user.id', ondelete='CASCADE'), nullable=False, ), - sa.Column("provider", sa.Text(), nullable=False), - sa.Column("token", sa.Text(), nullable=False), - sa.Column("expires_at", sa.BigInteger(), nullable=False), - sa.Column("created_at", sa.BigInteger(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.Column('provider', sa.Text(), nullable=False), + sa.Column('token', sa.Text(), nullable=False), + sa.Column('expires_at', sa.BigInteger(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), ) # Create indexes for better performance - op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"]) - op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"]) - op.create_index( - "idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"] - ) + op.create_index('idx_oauth_session_user_id', 'oauth_session', ['user_id']) + op.create_index('idx_oauth_session_expires_at', 'oauth_session', ['expires_at']) + op.create_index('idx_oauth_session_user_provider', 'oauth_session', ['user_id', 'provider']) def downgrade() -> None: # Drop indexes first - op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session") - op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session") - op.drop_index("idx_oauth_session_user_id", table_name="oauth_session") + op.drop_index('idx_oauth_session_user_provider', table_name='oauth_session') + op.drop_index('idx_oauth_session_expires_at', table_name='oauth_session') + op.drop_index('idx_oauth_session_user_id', table_name='oauth_session') # Drop the table - op.drop_table("oauth_session") + op.drop_table('oauth_session') diff --git a/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py b/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py index 6e010424b0..31bd355ede 100644 --- a/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py +++ b/backend/open_webui/migrations/versions/3ab32c4b8f59_update_tags.py @@ -13,8 +13,8 @@ import json -revision = "3ab32c4b8f59" -down_revision = "1af9b942657b" +revision = '3ab32c4b8f59' +down_revision = '1af9b942657b' branch_labels = None depends_on = None @@ -24,58 +24,55 @@ def upgrade(): inspector = Inspector.from_engine(conn) # Inspecting the 'tag' table constraints and structure - existing_pk = inspector.get_pk_constraint("tag") - unique_constraints = inspector.get_unique_constraints("tag") - existing_indexes = inspector.get_indexes("tag") + existing_pk = inspector.get_pk_constraint('tag') + unique_constraints = inspector.get_unique_constraints('tag') + existing_indexes = inspector.get_indexes('tag') - print(f"Primary Key: {existing_pk}") - print(f"Unique Constraints: {unique_constraints}") - print(f"Indexes: {existing_indexes}") + print(f'Primary Key: {existing_pk}') + print(f'Unique Constraints: {unique_constraints}') + print(f'Indexes: {existing_indexes}') - with op.batch_alter_table("tag", schema=None) as batch_op: + with op.batch_alter_table('tag', schema=None) as batch_op: # Drop existing primary key constraint if it exists - if existing_pk and existing_pk.get("constrained_columns"): - pk_name = existing_pk.get("name") + if existing_pk and existing_pk.get('constrained_columns'): + pk_name = existing_pk.get('name') if pk_name: - print(f"Dropping primary key constraint: {pk_name}") - batch_op.drop_constraint(pk_name, type_="primary") + print(f'Dropping primary key constraint: {pk_name}') + batch_op.drop_constraint(pk_name, type_='primary') # Now create the new primary key with the combination of 'id' and 'user_id' print("Creating new primary key with 'id' and 'user_id'.") - batch_op.create_primary_key("pk_id_user_id", ["id", "user_id"]) + batch_op.create_primary_key('pk_id_user_id', ['id', 'user_id']) # Drop unique constraints that could conflict with the new primary key for constraint in unique_constraints: if ( - constraint["name"] == "uq_id_user_id" + constraint['name'] == 'uq_id_user_id' ): # Adjust this name according to what is actually returned by the inspector - print(f"Dropping unique constraint: {constraint['name']}") - batch_op.drop_constraint(constraint["name"], type_="unique") + print(f'Dropping unique constraint: {constraint["name"]}') + batch_op.drop_constraint(constraint['name'], type_='unique') for index in existing_indexes: - if index["unique"]: - if not any( - constraint["name"] == index["name"] - for constraint in unique_constraints - ): + if index['unique']: + if not any(constraint['name'] == index['name'] for constraint in unique_constraints): # You are attempting to drop unique indexes - print(f"Dropping unique index: {index['name']}") - batch_op.drop_index(index["name"]) + print(f'Dropping unique index: {index["name"]}') + batch_op.drop_index(index['name']) def downgrade(): conn = op.get_bind() inspector = Inspector.from_engine(conn) - current_pk = inspector.get_pk_constraint("tag") + current_pk = inspector.get_pk_constraint('tag') - with op.batch_alter_table("tag", schema=None) as batch_op: + with op.batch_alter_table('tag', schema=None) as batch_op: # Drop the current primary key first, if it matches the one we know we added in upgrade - if current_pk and "pk_id_user_id" == current_pk.get("name"): - batch_op.drop_constraint("pk_id_user_id", type_="primary") + if current_pk and 'pk_id_user_id' == current_pk.get('name'): + batch_op.drop_constraint('pk_id_user_id', type_='primary') # Restore the original primary key - batch_op.create_primary_key("pk_id", ["id"]) + batch_op.create_primary_key('pk_id', ['id']) # Since primary key on just 'id' is restored, we now add back any unique constraints if necessary - batch_op.create_unique_constraint("uq_id_user_id", ["id", "user_id"]) + batch_op.create_unique_constraint('uq_id_user_id', ['id', 'user_id']) diff --git a/backend/open_webui/migrations/versions/3af16a1c9fb6_update_user_table.py b/backend/open_webui/migrations/versions/3af16a1c9fb6_update_user_table.py index ab980f27ce..629c1c8c24 100644 --- a/backend/open_webui/migrations/versions/3af16a1c9fb6_update_user_table.py +++ b/backend/open_webui/migrations/versions/3af16a1c9fb6_update_user_table.py @@ -12,21 +12,21 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = "3af16a1c9fb6" -down_revision: Union[str, None] = "018012973d35" +revision: str = '3af16a1c9fb6' +down_revision: Union[str, None] = '018012973d35' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.add_column("user", sa.Column("username", sa.String(length=50), nullable=True)) - op.add_column("user", sa.Column("bio", sa.Text(), nullable=True)) - op.add_column("user", sa.Column("gender", sa.Text(), nullable=True)) - op.add_column("user", sa.Column("date_of_birth", sa.Date(), nullable=True)) + op.add_column('user', sa.Column('username', sa.String(length=50), nullable=True)) + op.add_column('user', sa.Column('bio', sa.Text(), nullable=True)) + op.add_column('user', sa.Column('gender', sa.Text(), nullable=True)) + op.add_column('user', sa.Column('date_of_birth', sa.Date(), nullable=True)) def downgrade() -> None: - op.drop_column("user", "username") - op.drop_column("user", "bio") - op.drop_column("user", "gender") - op.drop_column("user", "date_of_birth") + op.drop_column('user', 'username') + op.drop_column('user', 'bio') + op.drop_column('user', 'gender') + op.drop_column('user', 'date_of_birth') diff --git a/backend/open_webui/migrations/versions/3e0e00844bb0_add_knowledge_file_table.py b/backend/open_webui/migrations/versions/3e0e00844bb0_add_knowledge_file_table.py index 82249bb278..f772987a44 100644 --- a/backend/open_webui/migrations/versions/3e0e00844bb0_add_knowledge_file_table.py +++ b/backend/open_webui/migrations/versions/3e0e00844bb0_add_knowledge_file_table.py @@ -18,38 +18,38 @@ import uuid # revision identifiers, used by Alembic. -revision: str = "3e0e00844bb0" -down_revision: Union[str, None] = "90ef40d4714e" +revision: str = '3e0e00844bb0' +down_revision: Union[str, None] = '90ef40d4714e' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( - "knowledge_file", - sa.Column("id", sa.Text(), primary_key=True), - sa.Column("user_id", sa.Text(), nullable=False), + 'knowledge_file', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('user_id', sa.Text(), nullable=False), sa.Column( - "knowledge_id", + 'knowledge_id', sa.Text(), - sa.ForeignKey("knowledge.id", ondelete="CASCADE"), + sa.ForeignKey('knowledge.id', ondelete='CASCADE'), nullable=False, ), sa.Column( - "file_id", + 'file_id', sa.Text(), - sa.ForeignKey("file.id", ondelete="CASCADE"), + sa.ForeignKey('file.id', ondelete='CASCADE'), nullable=False, ), - sa.Column("created_at", sa.BigInteger(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), # indexes - sa.Index("ix_knowledge_file_knowledge_id", "knowledge_id"), - sa.Index("ix_knowledge_file_file_id", "file_id"), - sa.Index("ix_knowledge_file_user_id", "user_id"), + sa.Index('ix_knowledge_file_knowledge_id', 'knowledge_id'), + sa.Index('ix_knowledge_file_file_id', 'file_id'), + sa.Index('ix_knowledge_file_user_id', 'user_id'), # unique constraints sa.UniqueConstraint( - "knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file" + 'knowledge_id', 'file_id', name='uq_knowledge_file_knowledge_file' ), # prevent duplicate entries ) @@ -57,35 +57,33 @@ def upgrade() -> None: # 2. Read existing group with user_ids JSON column knowledge_table = sa.Table( - "knowledge", + 'knowledge', sa.MetaData(), - sa.Column("id", sa.Text()), - sa.Column("user_id", sa.Text()), - sa.Column("data", sa.JSON()), # JSON stored as text in SQLite + PG + sa.Column('id', sa.Text()), + sa.Column('user_id', sa.Text()), + sa.Column('data', sa.JSON()), # JSON stored as text in SQLite + PG ) results = connection.execute( - sa.select( - knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data - ) + sa.select(knowledge_table.c.id, knowledge_table.c.user_id, knowledge_table.c.data) ).fetchall() # 3. Insert members into group_member table kf_table = sa.Table( - "knowledge_file", + 'knowledge_file', sa.MetaData(), - sa.Column("id", sa.Text()), - sa.Column("user_id", sa.Text()), - sa.Column("knowledge_id", sa.Text()), - sa.Column("file_id", sa.Text()), - sa.Column("created_at", sa.BigInteger()), - sa.Column("updated_at", sa.BigInteger()), + sa.Column('id', sa.Text()), + sa.Column('user_id', sa.Text()), + sa.Column('knowledge_id', sa.Text()), + sa.Column('file_id', sa.Text()), + sa.Column('created_at', sa.BigInteger()), + sa.Column('updated_at', sa.BigInteger()), ) file_table = sa.Table( - "file", + 'file', sa.MetaData(), - sa.Column("id", sa.Text()), + sa.Column('id', sa.Text()), ) now = int(time.time()) @@ -102,50 +100,48 @@ def upgrade() -> None: if not isinstance(data, dict): continue - file_ids = data.get("file_ids", []) + file_ids = data.get('file_ids', []) for file_id in file_ids: - file_exists = connection.execute( - sa.select(file_table.c.id).where(file_table.c.id == file_id) - ).fetchone() + file_exists = connection.execute(sa.select(file_table.c.id).where(file_table.c.id == file_id)).fetchone() if not file_exists: continue # skip non-existing files row = { - "id": str(uuid.uuid4()), - "user_id": user_id, - "knowledge_id": knowledge_id, - "file_id": file_id, - "created_at": now, - "updated_at": now, + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'knowledge_id': knowledge_id, + 'file_id': file_id, + 'created_at': now, + 'updated_at': now, } connection.execute(kf_table.insert().values(**row)) - with op.batch_alter_table("knowledge") as batch: - batch.drop_column("data") + with op.batch_alter_table('knowledge') as batch: + batch.drop_column('data') def downgrade() -> None: # 1. Add back the old data column - op.add_column("knowledge", sa.Column("data", sa.JSON(), nullable=True)) + op.add_column('knowledge', sa.Column('data', sa.JSON(), nullable=True)) connection = op.get_bind() # 2. Read knowledge_file entries and reconstruct data JSON knowledge_table = sa.Table( - "knowledge", + 'knowledge', sa.MetaData(), - sa.Column("id", sa.Text()), - sa.Column("data", sa.JSON()), + sa.Column('id', sa.Text()), + sa.Column('data', sa.JSON()), ) kf_table = sa.Table( - "knowledge_file", + 'knowledge_file', sa.MetaData(), - sa.Column("id", sa.Text()), - sa.Column("knowledge_id", sa.Text()), - sa.Column("file_id", sa.Text()), + sa.Column('id', sa.Text()), + sa.Column('knowledge_id', sa.Text()), + sa.Column('file_id', sa.Text()), ) results = connection.execute(sa.select(knowledge_table.c.id)).fetchall() @@ -157,13 +153,9 @@ def downgrade() -> None: file_ids_list = [fid for (fid,) in file_ids] - data_json = {"file_ids": file_ids_list} + data_json = {'file_ids': file_ids_list} - connection.execute( - knowledge_table.update() - .where(knowledge_table.c.id == knowledge_id) - .values(data=data_json) - ) + connection.execute(knowledge_table.update().where(knowledge_table.c.id == knowledge_id).values(data=data_json)) # 3. Drop the knowledge_file table - op.drop_table("knowledge_file") + op.drop_table('knowledge_file') diff --git a/backend/open_webui/migrations/versions/4ace53fd72c8_update_folder_table_datetime.py b/backend/open_webui/migrations/versions/4ace53fd72c8_update_folder_table_datetime.py index 16f7967c8e..91e0dce0be 100644 --- a/backend/open_webui/migrations/versions/4ace53fd72c8_update_folder_table_datetime.py +++ b/backend/open_webui/migrations/versions/4ace53fd72c8_update_folder_table_datetime.py @@ -9,56 +9,56 @@ from alembic import op import sqlalchemy as sa -revision = "4ace53fd72c8" -down_revision = "af906e964978" +revision = '4ace53fd72c8' +down_revision = 'af906e964978' branch_labels = None depends_on = None def upgrade(): # Perform safe alterations using batch operation - with op.batch_alter_table("folder", schema=None) as batch_op: + with op.batch_alter_table('folder', schema=None) as batch_op: # Step 1: Remove server defaults for created_at and updated_at batch_op.alter_column( - "created_at", + 'created_at', server_default=None, # Removing server default ) batch_op.alter_column( - "updated_at", + 'updated_at', server_default=None, # Removing server default ) # Step 2: Change the column types to BigInteger for created_at batch_op.alter_column( - "created_at", + 'created_at', type_=sa.BigInteger(), existing_type=sa.DateTime(), existing_nullable=False, - postgresql_using="extract(epoch from created_at)::bigint", # Conversion for PostgreSQL + postgresql_using='extract(epoch from created_at)::bigint', # Conversion for PostgreSQL ) # Change the column types to BigInteger for updated_at batch_op.alter_column( - "updated_at", + 'updated_at', type_=sa.BigInteger(), existing_type=sa.DateTime(), existing_nullable=False, - postgresql_using="extract(epoch from updated_at)::bigint", # Conversion for PostgreSQL + postgresql_using='extract(epoch from updated_at)::bigint', # Conversion for PostgreSQL ) def downgrade(): # Downgrade: Convert columns back to DateTime and restore defaults - with op.batch_alter_table("folder", schema=None) as batch_op: + with op.batch_alter_table('folder', schema=None) as batch_op: batch_op.alter_column( - "created_at", + 'created_at', type_=sa.DateTime(), existing_type=sa.BigInteger(), existing_nullable=False, server_default=sa.func.now(), # Restoring server default on downgrade ) batch_op.alter_column( - "updated_at", + 'updated_at', type_=sa.DateTime(), existing_type=sa.BigInteger(), existing_nullable=False, diff --git a/backend/open_webui/migrations/versions/57c599a3cb57_add_channel_table.py b/backend/open_webui/migrations/versions/57c599a3cb57_add_channel_table.py index 54176dc46e..79f0e8827e 100644 --- a/backend/open_webui/migrations/versions/57c599a3cb57_add_channel_table.py +++ b/backend/open_webui/migrations/versions/57c599a3cb57_add_channel_table.py @@ -9,40 +9,40 @@ from alembic import op import sqlalchemy as sa -revision = "57c599a3cb57" -down_revision = "922e7a387820" +revision = '57c599a3cb57' +down_revision = '922e7a387820' branch_labels = None depends_on = None def upgrade(): op.create_table( - "channel", - sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True), - sa.Column("user_id", sa.Text()), - sa.Column("name", sa.Text()), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("data", sa.JSON(), nullable=True), - sa.Column("meta", sa.JSON(), nullable=True), - sa.Column("access_control", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), + 'channel', + sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), + sa.Column('user_id', sa.Text()), + sa.Column('name', sa.Text()), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('access_control', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), ) op.create_table( - "message", - sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True), - sa.Column("user_id", sa.Text()), - sa.Column("channel_id", sa.Text(), nullable=True), - sa.Column("content", sa.Text()), - sa.Column("data", sa.JSON(), nullable=True), - sa.Column("meta", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), + 'message', + sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), + sa.Column('user_id', sa.Text()), + sa.Column('channel_id', sa.Text(), nullable=True), + sa.Column('content', sa.Text()), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), ) def downgrade(): - op.drop_table("channel") + op.drop_table('channel') - op.drop_table("message") + op.drop_table('message') diff --git a/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py b/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py index f3ef62fd64..2bd2d9fd60 100644 --- a/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py +++ b/backend/open_webui/migrations/versions/6283dc0e4d8d_add_channel_file_table.py @@ -13,41 +13,39 @@ import open_webui.internal.db # revision identifiers, used by Alembic. -revision: str = "6283dc0e4d8d" -down_revision: Union[str, None] = "3e0e00844bb0" +revision: str = '6283dc0e4d8d' +down_revision: Union[str, None] = '3e0e00844bb0' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( - "channel_file", - sa.Column("id", sa.Text(), primary_key=True), - sa.Column("user_id", sa.Text(), nullable=False), + 'channel_file', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('user_id', sa.Text(), nullable=False), sa.Column( - "channel_id", + 'channel_id', sa.Text(), - sa.ForeignKey("channel.id", ondelete="CASCADE"), + sa.ForeignKey('channel.id', ondelete='CASCADE'), nullable=False, ), sa.Column( - "file_id", + 'file_id', sa.Text(), - sa.ForeignKey("file.id", ondelete="CASCADE"), + sa.ForeignKey('file.id', ondelete='CASCADE'), nullable=False, ), - sa.Column("created_at", sa.BigInteger(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), # indexes - sa.Index("ix_channel_file_channel_id", "channel_id"), - sa.Index("ix_channel_file_file_id", "file_id"), - sa.Index("ix_channel_file_user_id", "user_id"), + sa.Index('ix_channel_file_channel_id', 'channel_id'), + sa.Index('ix_channel_file_file_id', 'file_id'), + sa.Index('ix_channel_file_user_id', 'user_id'), # unique constraints - sa.UniqueConstraint( - "channel_id", "file_id", name="uq_channel_file_channel_file" - ), # prevent duplicate entries + sa.UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'), # prevent duplicate entries ) def downgrade() -> None: - op.drop_table("channel_file") + op.drop_table('channel_file') diff --git a/backend/open_webui/migrations/versions/6a39f3d8e55c_add_knowledge_table.py b/backend/open_webui/migrations/versions/6a39f3d8e55c_add_knowledge_table.py index d6083d7177..c65ca01415 100644 --- a/backend/open_webui/migrations/versions/6a39f3d8e55c_add_knowledge_table.py +++ b/backend/open_webui/migrations/versions/6a39f3d8e55c_add_knowledge_table.py @@ -11,37 +11,37 @@ from sqlalchemy.sql import table, column, select import json -revision = "6a39f3d8e55c" -down_revision = "c0fbf31ca0db" +revision = '6a39f3d8e55c' +down_revision = 'c0fbf31ca0db' branch_labels = None depends_on = None def upgrade(): # Creating the 'knowledge' table - print("Creating knowledge table") + print('Creating knowledge table') knowledge_table = op.create_table( - "knowledge", - sa.Column("id", sa.Text(), primary_key=True), - sa.Column("user_id", sa.Text(), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("data", sa.JSON(), nullable=True), - sa.Column("meta", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=True), + 'knowledge', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=True), ) - print("Migrating data from document table to knowledge table") + print('Migrating data from document table to knowledge table') # Representation of the existing 'document' table document_table = table( - "document", - column("collection_name", sa.String()), - column("user_id", sa.String()), - column("name", sa.String()), - column("title", sa.Text()), - column("content", sa.Text()), - column("timestamp", sa.BigInteger()), + 'document', + column('collection_name', sa.String()), + column('user_id', sa.String()), + column('name', sa.String()), + column('title', sa.Text()), + column('content', sa.Text()), + column('timestamp', sa.BigInteger()), ) # Select all from existing document table @@ -64,9 +64,9 @@ def upgrade(): user_id=doc.user_id, description=doc.name, meta={ - "legacy": True, - "document": True, - "tags": json.loads(doc.content or "{}").get("tags", []), + 'legacy': True, + 'document': True, + 'tags': json.loads(doc.content or '{}').get('tags', []), }, name=doc.title, created_at=doc.timestamp, @@ -76,4 +76,4 @@ def upgrade(): def downgrade(): - op.drop_table("knowledge") + op.drop_table('knowledge') diff --git a/backend/open_webui/migrations/versions/7826ab40b532_update_file_table.py b/backend/open_webui/migrations/versions/7826ab40b532_update_file_table.py index c8afe9d51a..4211c6642e 100644 --- a/backend/open_webui/migrations/versions/7826ab40b532_update_file_table.py +++ b/backend/open_webui/migrations/versions/7826ab40b532_update_file_table.py @@ -9,18 +9,18 @@ from alembic import op import sqlalchemy as sa -revision = "7826ab40b532" -down_revision = "57c599a3cb57" +revision = '7826ab40b532' +down_revision = '57c599a3cb57' branch_labels = None depends_on = None def upgrade(): op.add_column( - "file", - sa.Column("access_control", sa.JSON(), nullable=True), + 'file', + sa.Column('access_control', sa.JSON(), nullable=True), ) def downgrade(): - op.drop_column("file", "access_control") + op.drop_column('file', 'access_control') diff --git a/backend/open_webui/migrations/versions/7e5b5dc7342b_init.py b/backend/open_webui/migrations/versions/7e5b5dc7342b_init.py index 9e56282ef0..39f488d72e 100644 --- a/backend/open_webui/migrations/versions/7e5b5dc7342b_init.py +++ b/backend/open_webui/migrations/versions/7e5b5dc7342b_init.py @@ -16,7 +16,7 @@ from open_webui.migrations.util import get_existing_tables # revision identifiers, used by Alembic. -revision: str = "7e5b5dc7342b" +revision: str = '7e5b5dc7342b' down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -26,179 +26,179 @@ def upgrade() -> None: existing_tables = set(get_existing_tables()) # ### commands auto generated by Alembic - please adjust! ### - if "auth" not in existing_tables: + if 'auth' not in existing_tables: op.create_table( - "auth", - sa.Column("id", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=True), - sa.Column("password", sa.Text(), nullable=True), - sa.Column("active", sa.Boolean(), nullable=True), - sa.PrimaryKeyConstraint("id"), + 'auth', + sa.Column('id', sa.String(), nullable=False), + sa.Column('email', sa.String(), nullable=True), + sa.Column('password', sa.Text(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) - if "chat" not in existing_tables: + if 'chat' not in existing_tables: op.create_table( - "chat", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("title", sa.Text(), nullable=True), - sa.Column("chat", sa.Text(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), - sa.Column("share_id", sa.Text(), nullable=True), - sa.Column("archived", sa.Boolean(), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("share_id"), + 'chat', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('chat', sa.Text(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + sa.Column('share_id', sa.Text(), nullable=True), + sa.Column('archived', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('share_id'), ) - if "chatidtag" not in existing_tables: + if 'chatidtag' not in existing_tables: op.create_table( - "chatidtag", - sa.Column("id", sa.String(), nullable=False), - sa.Column("tag_name", sa.String(), nullable=True), - sa.Column("chat_id", sa.String(), nullable=True), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("timestamp", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), + 'chatidtag', + sa.Column('id', sa.String(), nullable=False), + sa.Column('tag_name', sa.String(), nullable=True), + sa.Column('chat_id', sa.String(), nullable=True), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('timestamp', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) - if "document" not in existing_tables: + if 'document' not in existing_tables: op.create_table( - "document", - sa.Column("collection_name", sa.String(), nullable=False), - sa.Column("name", sa.String(), nullable=True), - sa.Column("title", sa.Text(), nullable=True), - sa.Column("filename", sa.Text(), nullable=True), - sa.Column("content", sa.Text(), nullable=True), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("timestamp", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("collection_name"), - sa.UniqueConstraint("name"), + 'document', + sa.Column('collection_name', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('filename', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('timestamp', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('collection_name'), + sa.UniqueConstraint('name'), ) - if "file" not in existing_tables: + if 'file' not in existing_tables: op.create_table( - "file", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("filename", sa.Text(), nullable=True), - sa.Column("meta", JSONField(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), + 'file', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('filename', sa.Text(), nullable=True), + sa.Column('meta', JSONField(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) - if "function" not in existing_tables: + if 'function' not in existing_tables: op.create_table( - "function", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("name", sa.Text(), nullable=True), - sa.Column("type", sa.Text(), nullable=True), - sa.Column("content", sa.Text(), nullable=True), - sa.Column("meta", JSONField(), nullable=True), - sa.Column("valves", JSONField(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=True), - sa.Column("is_global", sa.Boolean(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), + 'function', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('name', sa.Text(), nullable=True), + sa.Column('type', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('meta', JSONField(), nullable=True), + sa.Column('valves', JSONField(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_global', sa.Boolean(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) - if "memory" not in existing_tables: + if 'memory' not in existing_tables: op.create_table( - "memory", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("content", sa.Text(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), + 'memory', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) - if "model" not in existing_tables: + if 'model' not in existing_tables: op.create_table( - "model", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("user_id", sa.Text(), nullable=True), - sa.Column("base_model_id", sa.Text(), nullable=True), - sa.Column("name", sa.Text(), nullable=True), - sa.Column("params", JSONField(), nullable=True), - sa.Column("meta", JSONField(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), + 'model', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=True), + sa.Column('base_model_id', sa.Text(), nullable=True), + sa.Column('name', sa.Text(), nullable=True), + sa.Column('params', JSONField(), nullable=True), + sa.Column('meta', JSONField(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) - if "prompt" not in existing_tables: + if 'prompt' not in existing_tables: op.create_table( - "prompt", - sa.Column("command", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("title", sa.Text(), nullable=True), - sa.Column("content", sa.Text(), nullable=True), - sa.Column("timestamp", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("command"), + 'prompt', + sa.Column('command', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('timestamp', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('command'), ) - if "tag" not in existing_tables: + if 'tag' not in existing_tables: op.create_table( - "tag", - sa.Column("id", sa.String(), nullable=False), - sa.Column("name", sa.String(), nullable=True), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("data", sa.Text(), nullable=True), - sa.PrimaryKeyConstraint("id"), + 'tag', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('data', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) - if "tool" not in existing_tables: + if 'tool' not in existing_tables: op.create_table( - "tool", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("name", sa.Text(), nullable=True), - sa.Column("content", sa.Text(), nullable=True), - sa.Column("specs", JSONField(), nullable=True), - sa.Column("meta", JSONField(), nullable=True), - sa.Column("valves", JSONField(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), + 'tool', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('name', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('specs', JSONField(), nullable=True), + sa.Column('meta', JSONField(), nullable=True), + sa.Column('valves', JSONField(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) - if "user" not in existing_tables: + if 'user' not in existing_tables: op.create_table( - "user", - sa.Column("id", sa.String(), nullable=False), - sa.Column("name", sa.String(), nullable=True), - sa.Column("email", sa.String(), nullable=True), - sa.Column("role", sa.String(), nullable=True), - sa.Column("profile_image_url", sa.Text(), nullable=True), - sa.Column("last_active_at", sa.BigInteger(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.Column("api_key", sa.String(), nullable=True), - sa.Column("settings", JSONField(), nullable=True), - sa.Column("info", JSONField(), nullable=True), - sa.Column("oauth_sub", sa.Text(), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("api_key"), - sa.UniqueConstraint("oauth_sub"), + 'user', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('email', sa.String(), nullable=True), + sa.Column('role', sa.String(), nullable=True), + sa.Column('profile_image_url', sa.Text(), nullable=True), + sa.Column('last_active_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('api_key', sa.String(), nullable=True), + sa.Column('settings', JSONField(), nullable=True), + sa.Column('info', JSONField(), nullable=True), + sa.Column('oauth_sub', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('api_key'), + sa.UniqueConstraint('oauth_sub'), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("user") - op.drop_table("tool") - op.drop_table("tag") - op.drop_table("prompt") - op.drop_table("model") - op.drop_table("memory") - op.drop_table("function") - op.drop_table("file") - op.drop_table("document") - op.drop_table("chatidtag") - op.drop_table("chat") - op.drop_table("auth") + op.drop_table('user') + op.drop_table('tool') + op.drop_table('tag') + op.drop_table('prompt') + op.drop_table('model') + op.drop_table('memory') + op.drop_table('function') + op.drop_table('file') + op.drop_table('document') + op.drop_table('chatidtag') + op.drop_table('chat') + op.drop_table('auth') # ### end Alembic commands ### diff --git a/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py b/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py index 3853ec50d9..e45a2443df 100644 --- a/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py +++ b/backend/open_webui/migrations/versions/81cc2ce44d79_update_channel_file_and_knowledge_table.py @@ -13,36 +13,34 @@ import open_webui.internal.db # revision identifiers, used by Alembic. -revision: str = "81cc2ce44d79" -down_revision: Union[str, None] = "6283dc0e4d8d" +revision: str = '81cc2ce44d79' +down_revision: Union[str, None] = '6283dc0e4d8d' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # Add message_id column to channel_file table - with op.batch_alter_table("channel_file", schema=None) as batch_op: + with op.batch_alter_table('channel_file', schema=None) as batch_op: batch_op.add_column( sa.Column( - "message_id", + 'message_id', sa.Text(), - sa.ForeignKey( - "message.id", ondelete="CASCADE", name="fk_channel_file_message_id" - ), + sa.ForeignKey('message.id', ondelete='CASCADE', name='fk_channel_file_message_id'), nullable=True, ) ) # Add data column to knowledge table - with op.batch_alter_table("knowledge", schema=None) as batch_op: - batch_op.add_column(sa.Column("data", sa.JSON(), nullable=True)) + with op.batch_alter_table('knowledge', schema=None) as batch_op: + batch_op.add_column(sa.Column('data', sa.JSON(), nullable=True)) def downgrade() -> None: # Remove message_id column from channel_file table - with op.batch_alter_table("channel_file", schema=None) as batch_op: - batch_op.drop_column("message_id") + with op.batch_alter_table('channel_file', schema=None) as batch_op: + batch_op.drop_column('message_id') # Remove data column from knowledge table - with op.batch_alter_table("knowledge", schema=None) as batch_op: - batch_op.drop_column("data") + with op.batch_alter_table('knowledge', schema=None) as batch_op: + batch_op.drop_column('data') diff --git a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py index aa9b0a4c26..3254b57858 100644 --- a/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py +++ b/backend/open_webui/migrations/versions/8452d01d26d7_add_chat_message_table.py @@ -16,8 +16,8 @@ log = logging.getLogger(__name__) -revision: str = "8452d01d26d7" -down_revision: Union[str, None] = "374d2f66af06" +revision: str = '8452d01d26d7' +down_revision: Union[str, None] = '374d2f66af06' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -51,74 +51,68 @@ def _flush_batch(conn, table, batch): except Exception as e: sp.rollback() failed += 1 - log.warning(f"Failed to insert message {msg['id']}: {e}") + log.warning(f'Failed to insert message {msg["id"]}: {e}') return inserted, failed def upgrade() -> None: # Step 1: Create table op.create_table( - "chat_message", - sa.Column("id", sa.Text(), primary_key=True), - sa.Column("chat_id", sa.Text(), nullable=False, index=True), - sa.Column("user_id", sa.Text(), index=True), - sa.Column("role", sa.Text(), nullable=False), - sa.Column("parent_id", sa.Text(), nullable=True), - sa.Column("content", sa.JSON(), nullable=True), - sa.Column("output", sa.JSON(), nullable=True), - sa.Column("model_id", sa.Text(), nullable=True, index=True), - sa.Column("files", sa.JSON(), nullable=True), - sa.Column("sources", sa.JSON(), nullable=True), - sa.Column("embeds", sa.JSON(), nullable=True), - sa.Column("done", sa.Boolean(), default=True), - sa.Column("status_history", sa.JSON(), nullable=True), - sa.Column("error", sa.JSON(), nullable=True), - sa.Column("usage", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), index=True), - sa.Column("updated_at", sa.BigInteger()), - sa.ForeignKeyConstraint(["chat_id"], ["chat.id"], ondelete="CASCADE"), + 'chat_message', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('chat_id', sa.Text(), nullable=False, index=True), + sa.Column('user_id', sa.Text(), index=True), + sa.Column('role', sa.Text(), nullable=False), + sa.Column('parent_id', sa.Text(), nullable=True), + sa.Column('content', sa.JSON(), nullable=True), + sa.Column('output', sa.JSON(), nullable=True), + sa.Column('model_id', sa.Text(), nullable=True, index=True), + sa.Column('files', sa.JSON(), nullable=True), + sa.Column('sources', sa.JSON(), nullable=True), + sa.Column('embeds', sa.JSON(), nullable=True), + sa.Column('done', sa.Boolean(), default=True), + sa.Column('status_history', sa.JSON(), nullable=True), + sa.Column('error', sa.JSON(), nullable=True), + sa.Column('usage', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), index=True), + sa.Column('updated_at', sa.BigInteger()), + sa.ForeignKeyConstraint(['chat_id'], ['chat.id'], ondelete='CASCADE'), ) # Create composite indexes - op.create_index( - "chat_message_chat_parent_idx", "chat_message", ["chat_id", "parent_id"] - ) - op.create_index( - "chat_message_model_created_idx", "chat_message", ["model_id", "created_at"] - ) - op.create_index( - "chat_message_user_created_idx", "chat_message", ["user_id", "created_at"] - ) + op.create_index('chat_message_chat_parent_idx', 'chat_message', ['chat_id', 'parent_id']) + op.create_index('chat_message_model_created_idx', 'chat_message', ['model_id', 'created_at']) + op.create_index('chat_message_user_created_idx', 'chat_message', ['user_id', 'created_at']) # Step 2: Backfill from existing chats conn = op.get_bind() chat_table = sa.table( - "chat", - sa.column("id", sa.Text()), - sa.column("user_id", sa.Text()), - sa.column("chat", sa.JSON()), + 'chat', + sa.column('id', sa.Text()), + sa.column('user_id', sa.Text()), + sa.column('chat', sa.JSON()), ) chat_message_table = sa.table( - "chat_message", - sa.column("id", sa.Text()), - sa.column("chat_id", sa.Text()), - sa.column("user_id", sa.Text()), - sa.column("role", sa.Text()), - sa.column("parent_id", sa.Text()), - sa.column("content", sa.JSON()), - sa.column("output", sa.JSON()), - sa.column("model_id", sa.Text()), - sa.column("files", sa.JSON()), - sa.column("sources", sa.JSON()), - sa.column("embeds", sa.JSON()), - sa.column("done", sa.Boolean()), - sa.column("status_history", sa.JSON()), - sa.column("error", sa.JSON()), - sa.column("usage", sa.JSON()), - sa.column("created_at", sa.BigInteger()), - sa.column("updated_at", sa.BigInteger()), + 'chat_message', + sa.column('id', sa.Text()), + sa.column('chat_id', sa.Text()), + sa.column('user_id', sa.Text()), + sa.column('role', sa.Text()), + sa.column('parent_id', sa.Text()), + sa.column('content', sa.JSON()), + sa.column('output', sa.JSON()), + sa.column('model_id', sa.Text()), + sa.column('files', sa.JSON()), + sa.column('sources', sa.JSON()), + sa.column('embeds', sa.JSON()), + sa.column('done', sa.Boolean()), + sa.column('status_history', sa.JSON()), + sa.column('error', sa.JSON()), + sa.column('usage', sa.JSON()), + sa.column('created_at', sa.BigInteger()), + sa.column('updated_at', sa.BigInteger()), ) # Stream rows instead of loading all into memory: @@ -126,7 +120,7 @@ def upgrade() -> None: # - stream_results: enables server-side cursors on PostgreSQL (no-op on SQLite) result = conn.execute( sa.select(chat_table.c.id, chat_table.c.user_id, chat_table.c.chat) - .where(~chat_table.c.user_id.like("shared-%")) + .where(~chat_table.c.user_id.like('shared-%')) .execution_options(yield_per=1000, stream_results=True) ) @@ -150,18 +144,23 @@ def upgrade() -> None: except Exception: continue - history = chat_data.get("history", {}) - messages = history.get("messages", {}) + history = chat_data.get('history', {}) + if not isinstance(history, dict): + continue + + messages = history.get('messages', {}) + if not isinstance(messages, dict): + continue for message_id, message in messages.items(): if not isinstance(message, dict): continue - role = message.get("role") + role = message.get('role') if not role: continue - timestamp = message.get("timestamp", now) + timestamp = message.get('timestamp', now) try: timestamp = int(float(timestamp)) @@ -177,37 +176,33 @@ def upgrade() -> None: messages_batch.append( { - "id": f"{chat_id}-{message_id}", - "chat_id": chat_id, - "user_id": user_id, - "role": role, - "parent_id": message.get("parentId"), - "content": message.get("content"), - "output": message.get("output"), - "model_id": message.get("model"), - "files": message.get("files"), - "sources": message.get("sources"), - "embeds": message.get("embeds"), - "done": message.get("done", True), - "status_history": message.get("statusHistory"), - "error": message.get("error"), - "usage": message.get("usage"), - "created_at": timestamp, - "updated_at": timestamp, + 'id': f'{chat_id}-{message_id}', + 'chat_id': chat_id, + 'user_id': user_id, + 'role': role, + 'parent_id': message.get('parentId'), + 'content': message.get('content'), + 'output': message.get('output'), + 'model_id': message.get('model'), + 'files': message.get('files'), + 'sources': message.get('sources'), + 'embeds': message.get('embeds'), + 'done': message.get('done', True), + 'status_history': message.get('statusHistory'), + 'error': message.get('error'), + 'usage': message.get('usage'), + 'created_at': timestamp, + 'updated_at': timestamp, } ) # Flush batch when full if len(messages_batch) >= BATCH_SIZE: - inserted, failed = _flush_batch( - conn, chat_message_table, messages_batch - ) + inserted, failed = _flush_batch(conn, chat_message_table, messages_batch) total_inserted += inserted total_failed += failed if total_inserted % 50000 < BATCH_SIZE: - log.info( - f"Migration progress: {total_inserted} messages inserted..." - ) + log.info(f'Migration progress: {total_inserted} messages inserted...') messages_batch.clear() # Flush remaining messages @@ -216,13 +211,11 @@ def upgrade() -> None: total_inserted += inserted total_failed += failed - log.info( - f"Backfilled {total_inserted} messages into chat_message table ({total_failed} failed)" - ) + log.info(f'Backfilled {total_inserted} messages into chat_message table ({total_failed} failed)') def downgrade() -> None: - op.drop_index("chat_message_user_created_idx", table_name="chat_message") - op.drop_index("chat_message_model_created_idx", table_name="chat_message") - op.drop_index("chat_message_chat_parent_idx", table_name="chat_message") - op.drop_table("chat_message") + op.drop_index('chat_message_user_created_idx', table_name='chat_message') + op.drop_index('chat_message_model_created_idx', table_name='chat_message') + op.drop_index('chat_message_chat_parent_idx', table_name='chat_message') + op.drop_table('chat_message') diff --git a/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py index 8b9e338309..9d115b1e5c 100644 --- a/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py +++ b/backend/open_webui/migrations/versions/90ef40d4714e_update_channel_and_channel_members_table.py @@ -13,48 +13,46 @@ import open_webui.internal.db # revision identifiers, used by Alembic. -revision: str = "90ef40d4714e" -down_revision: Union[str, None] = "b10670c03dd5" +revision: str = '90ef40d4714e' +down_revision: Union[str, None] = 'b10670c03dd5' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # Update 'channel' table - op.add_column("channel", sa.Column("is_private", sa.Boolean(), nullable=True)) + op.add_column('channel', sa.Column('is_private', sa.Boolean(), nullable=True)) - op.add_column("channel", sa.Column("archived_at", sa.BigInteger(), nullable=True)) - op.add_column("channel", sa.Column("archived_by", sa.Text(), nullable=True)) + op.add_column('channel', sa.Column('archived_at', sa.BigInteger(), nullable=True)) + op.add_column('channel', sa.Column('archived_by', sa.Text(), nullable=True)) - op.add_column("channel", sa.Column("deleted_at", sa.BigInteger(), nullable=True)) - op.add_column("channel", sa.Column("deleted_by", sa.Text(), nullable=True)) + op.add_column('channel', sa.Column('deleted_at', sa.BigInteger(), nullable=True)) + op.add_column('channel', sa.Column('deleted_by', sa.Text(), nullable=True)) - op.add_column("channel", sa.Column("updated_by", sa.Text(), nullable=True)) + op.add_column('channel', sa.Column('updated_by', sa.Text(), nullable=True)) # Update 'channel_member' table - op.add_column("channel_member", sa.Column("role", sa.Text(), nullable=True)) - op.add_column("channel_member", sa.Column("invited_by", sa.Text(), nullable=True)) - op.add_column( - "channel_member", sa.Column("invited_at", sa.BigInteger(), nullable=True) - ) + op.add_column('channel_member', sa.Column('role', sa.Text(), nullable=True)) + op.add_column('channel_member', sa.Column('invited_by', sa.Text(), nullable=True)) + op.add_column('channel_member', sa.Column('invited_at', sa.BigInteger(), nullable=True)) # Create 'channel_webhook' table op.create_table( - "channel_webhook", - sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False), - sa.Column("user_id", sa.Text(), nullable=False), + 'channel_webhook', + sa.Column('id', sa.Text(), primary_key=True, unique=True, nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), sa.Column( - "channel_id", + 'channel_id', sa.Text(), - sa.ForeignKey("channel.id", ondelete="CASCADE"), + sa.ForeignKey('channel.id', ondelete='CASCADE'), nullable=False, ), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("profile_image_url", sa.Text(), nullable=True), - sa.Column("token", sa.Text(), nullable=False), - sa.Column("last_used_at", sa.BigInteger(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('profile_image_url', sa.Text(), nullable=True), + sa.Column('token', sa.Text(), nullable=False), + sa.Column('last_used_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), ) pass @@ -62,19 +60,19 @@ def upgrade() -> None: def downgrade() -> None: # Downgrade 'channel' table - op.drop_column("channel", "is_private") - op.drop_column("channel", "archived_at") - op.drop_column("channel", "archived_by") - op.drop_column("channel", "deleted_at") - op.drop_column("channel", "deleted_by") - op.drop_column("channel", "updated_by") + op.drop_column('channel', 'is_private') + op.drop_column('channel', 'archived_at') + op.drop_column('channel', 'archived_by') + op.drop_column('channel', 'deleted_at') + op.drop_column('channel', 'deleted_by') + op.drop_column('channel', 'updated_by') # Downgrade 'channel_member' table - op.drop_column("channel_member", "role") - op.drop_column("channel_member", "invited_by") - op.drop_column("channel_member", "invited_at") + op.drop_column('channel_member', 'role') + op.drop_column('channel_member', 'invited_by') + op.drop_column('channel_member', 'invited_at') # Drop 'channel_webhook' table - op.drop_table("channel_webhook") + op.drop_table('channel_webhook') pass diff --git a/backend/open_webui/migrations/versions/922e7a387820_add_group_table.py b/backend/open_webui/migrations/versions/922e7a387820_add_group_table.py index a752115844..5e617be1e6 100644 --- a/backend/open_webui/migrations/versions/922e7a387820_add_group_table.py +++ b/backend/open_webui/migrations/versions/922e7a387820_add_group_table.py @@ -9,38 +9,38 @@ from alembic import op import sqlalchemy as sa -revision = "922e7a387820" -down_revision = "4ace53fd72c8" +revision = '922e7a387820' +down_revision = '4ace53fd72c8' branch_labels = None depends_on = None def upgrade(): op.create_table( - "group", - sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True), - sa.Column("user_id", sa.Text(), nullable=True), - sa.Column("name", sa.Text(), nullable=True), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("data", sa.JSON(), nullable=True), - sa.Column("meta", sa.JSON(), nullable=True), - sa.Column("permissions", sa.JSON(), nullable=True), - sa.Column("user_ids", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), + 'group', + sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), + sa.Column('user_id', sa.Text(), nullable=True), + sa.Column('name', sa.Text(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('permissions', sa.JSON(), nullable=True), + sa.Column('user_ids', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), ) # Add 'access_control' column to 'model' table op.add_column( - "model", - sa.Column("access_control", sa.JSON(), nullable=True), + 'model', + sa.Column('access_control', sa.JSON(), nullable=True), ) # Add 'is_active' column to 'model' table op.add_column( - "model", + 'model', sa.Column( - "is_active", + 'is_active', sa.Boolean(), nullable=False, server_default=sa.sql.expression.true(), @@ -49,37 +49,37 @@ def upgrade(): # Add 'access_control' column to 'knowledge' table op.add_column( - "knowledge", - sa.Column("access_control", sa.JSON(), nullable=True), + 'knowledge', + sa.Column('access_control', sa.JSON(), nullable=True), ) # Add 'access_control' column to 'prompt' table op.add_column( - "prompt", - sa.Column("access_control", sa.JSON(), nullable=True), + 'prompt', + sa.Column('access_control', sa.JSON(), nullable=True), ) # Add 'access_control' column to 'tools' table op.add_column( - "tool", - sa.Column("access_control", sa.JSON(), nullable=True), + 'tool', + sa.Column('access_control', sa.JSON(), nullable=True), ) def downgrade(): - op.drop_table("group") + op.drop_table('group') # Drop 'access_control' column from 'model' table - op.drop_column("model", "access_control") + op.drop_column('model', 'access_control') # Drop 'is_active' column from 'model' table - op.drop_column("model", "is_active") + op.drop_column('model', 'is_active') # Drop 'access_control' column from 'knowledge' table - op.drop_column("knowledge", "access_control") + op.drop_column('knowledge', 'access_control') # Drop 'access_control' column from 'prompt' table - op.drop_column("prompt", "access_control") + op.drop_column('prompt', 'access_control') # Drop 'access_control' column from 'tools' table - op.drop_column("tool", "access_control") + op.drop_column('tool', 'access_control') diff --git a/backend/open_webui/migrations/versions/97c08d196e3d_init_redemption.py b/backend/open_webui/migrations/versions/97c08d196e3d_init_redemption.py index 4f951993c4..a94dcdf651 100644 --- a/backend/open_webui/migrations/versions/97c08d196e3d_init_redemption.py +++ b/backend/open_webui/migrations/versions/97c08d196e3d_init_redemption.py @@ -14,8 +14,8 @@ from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision: str = "97c08d196e3d" -down_revision: Union[str, None] = "d31026856c01" +revision: str = '97c08d196e3d' +down_revision: Union[str, None] = 'd31026856c01' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -23,26 +23,26 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "redemption_code", - sa.Column("code", sa.String(), nullable=False), - sa.Column("purpose", sa.String(), nullable=True), - sa.Column("user_id", sa.String(), nullable=True), - sa.Column("amount", sa.Numeric(precision=24, scale=12), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.Column("expired_at", sa.BigInteger(), nullable=True), - sa.Column("received_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("code"), + 'redemption_code', + sa.Column('code', sa.String(), nullable=False), + sa.Column('purpose', sa.String(), nullable=True), + sa.Column('user_id', sa.String(), nullable=True), + sa.Column('amount', sa.Numeric(precision=24, scale=12), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('expired_at', sa.BigInteger(), nullable=True), + sa.Column('received_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('code'), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_index("function_id", "function", ["id"], unique=1) - op.drop_index(op.f("ix_redemption_code_user_id"), table_name="redemption_code") - op.drop_index(op.f("ix_redemption_code_received_at"), table_name="redemption_code") - op.drop_index(op.f("ix_redemption_code_purpose"), table_name="redemption_code") - op.drop_index(op.f("ix_redemption_code_expired_at"), table_name="redemption_code") - op.drop_index(op.f("ix_redemption_code_created_at"), table_name="redemption_code") - op.drop_table("redemption_code") + op.create_index('function_id', 'function', ['id'], unique=1) + op.drop_index(op.f('ix_redemption_code_user_id'), table_name='redemption_code') + op.drop_index(op.f('ix_redemption_code_received_at'), table_name='redemption_code') + op.drop_index(op.f('ix_redemption_code_purpose'), table_name='redemption_code') + op.drop_index(op.f('ix_redemption_code_expired_at'), table_name='redemption_code') + op.drop_index(op.f('ix_redemption_code_created_at'), table_name='redemption_code') + op.drop_table('redemption_code') # ### end Alembic commands ### diff --git a/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py b/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py index 722f9823eb..862b2c7b28 100644 --- a/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py +++ b/backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py @@ -9,25 +9,25 @@ from alembic import op import sqlalchemy as sa -revision = "9f0c9cd09105" -down_revision = "1403e6d80d1d" +revision = '9f0c9cd09105' +down_revision = '1403e6d80d1d' branch_labels = None depends_on = None def upgrade(): op.create_table( - "note", - sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True), - sa.Column("user_id", sa.Text(), nullable=True), - sa.Column("title", sa.Text(), nullable=True), - sa.Column("data", sa.JSON(), nullable=True), - sa.Column("meta", sa.JSON(), nullable=True), - sa.Column("access_control", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), + 'note', + sa.Column('id', sa.Text(), nullable=False, primary_key=True, unique=True), + sa.Column('user_id', sa.Text(), nullable=True), + sa.Column('title', sa.Text(), nullable=True), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('access_control', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), ) def downgrade(): - op.drop_table("note") + op.drop_table('note') diff --git a/backend/open_webui/migrations/versions/a0e430ed5341_add_trade_ticket.py b/backend/open_webui/migrations/versions/a0e430ed5341_add_trade_ticket.py index d6a81bef77..6c678f8b85 100644 --- a/backend/open_webui/migrations/versions/a0e430ed5341_add_trade_ticket.py +++ b/backend/open_webui/migrations/versions/a0e430ed5341_add_trade_ticket.py @@ -14,8 +14,8 @@ from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision: str = "a0e430ed5341" -down_revision: Union[str, None] = "a959f8a63245" +revision: str = 'a0e430ed5341' +down_revision: Union[str, None] = 'a959f8a63245' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -23,22 +23,20 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "trade_ticket", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("amount", sa.Numeric(precision=24, scale=12), nullable=True), - sa.Column("detail", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_trade_ticket_user_id"), "trade_ticket", ["user_id"], unique=False + 'trade_ticket', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('amount', sa.Numeric(precision=24, scale=12), nullable=True), + sa.Column('detail', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) + op.create_index(op.f('ix_trade_ticket_user_id'), 'trade_ticket', ['user_id'], unique=False) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f("ix_trade_ticket_user_id"), table_name="trade_ticket") - op.drop_table("trade_ticket") + op.drop_index(op.f('ix_trade_ticket_user_id'), table_name='trade_ticket') + op.drop_table('trade_ticket') # ### end Alembic commands ### diff --git a/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py b/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py index 26e9e66240..f11f7d8d1b 100644 --- a/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py +++ b/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py @@ -13,8 +13,8 @@ from open_webui.migrations.util import get_existing_tables -revision: str = "a1b2c3d4e5f6" -down_revision: Union[str, None] = "f1e2d3c4b5a6" +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = 'f1e2d3c4b5a6' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -22,24 +22,24 @@ def upgrade() -> None: existing_tables = set(get_existing_tables()) - if "skill" not in existing_tables: + if 'skill' not in existing_tables: op.create_table( - "skill", - sa.Column("id", sa.String(), nullable=False, primary_key=True), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("name", sa.Text(), nullable=False, unique=True), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("meta", sa.JSON(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=False), - sa.Column("created_at", sa.BigInteger(), nullable=False), + 'skill', + sa.Column('id', sa.String(), nullable=False, primary_key=True), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('name', sa.Text(), nullable=False, unique=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), ) - op.create_index("idx_skill_user_id", "skill", ["user_id"]) - op.create_index("idx_skill_updated_at", "skill", ["updated_at"]) + op.create_index('idx_skill_user_id', 'skill', ['user_id']) + op.create_index('idx_skill_updated_at', 'skill', ['updated_at']) def downgrade() -> None: - op.drop_index("idx_skill_updated_at", table_name="skill") - op.drop_index("idx_skill_user_id", table_name="skill") - op.drop_table("skill") + op.drop_index('idx_skill_updated_at', table_name='skill') + op.drop_index('idx_skill_user_id', table_name='skill') + op.drop_table('skill') diff --git a/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py b/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py index dd2b7d1a68..29157baa07 100644 --- a/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py +++ b/backend/open_webui/migrations/versions/a5c220713937_add_reply_to_id_column_to_message.py @@ -12,8 +12,8 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = "a5c220713937" -down_revision: Union[str, None] = "38d63c18f30f" +revision: str = 'a5c220713937' +down_revision: Union[str, None] = '38d63c18f30f' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,14 +21,14 @@ def upgrade() -> None: # Add 'reply_to_id' column to the 'message' table for replying to messages op.add_column( - "message", - sa.Column("reply_to_id", sa.Text(), nullable=True), + 'message', + sa.Column('reply_to_id', sa.Text(), nullable=True), ) pass def downgrade() -> None: # Remove 'reply_to_id' column from the 'message' table - op.drop_column("message", "reply_to_id") + op.drop_column('message', 'reply_to_id') pass diff --git a/backend/open_webui/migrations/versions/a7dd10d9b220_add_index_to_credit_log.py b/backend/open_webui/migrations/versions/a7dd10d9b220_add_index_to_credit_log.py index b127e1d639..d47625defb 100644 --- a/backend/open_webui/migrations/versions/a7dd10d9b220_add_index_to_credit_log.py +++ b/backend/open_webui/migrations/versions/a7dd10d9b220_add_index_to_credit_log.py @@ -14,21 +14,19 @@ from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision: str = "a7dd10d9b220" -down_revision: Union[str, None] = "a0e430ed5341" +revision: str = 'a7dd10d9b220' +down_revision: Union[str, None] = 'a0e430ed5341' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_index( - op.f("ix_credit_log_created_at"), "credit_log", ["created_at"], unique=False - ) + op.create_index(op.f('ix_credit_log_created_at'), 'credit_log', ['created_at'], unique=False) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f("ix_credit_log_created_at"), table_name="credit_log") + op.drop_index(op.f('ix_credit_log_created_at'), table_name='credit_log') # ### end Alembic commands ### diff --git a/backend/open_webui/migrations/versions/a959f8a63245_add_credits.py b/backend/open_webui/migrations/versions/a959f8a63245_add_credits.py index b75161bfcb..d16575b7e6 100644 --- a/backend/open_webui/migrations/versions/a959f8a63245_add_credits.py +++ b/backend/open_webui/migrations/versions/a959f8a63245_add_credits.py @@ -15,8 +15,8 @@ import open_webui.internal.db # revision identifiers, used by Alembic. -revision: str = "a959f8a63245" -down_revision: Union[str, None] = "3781e22d8b01" +revision: str = 'a959f8a63245' +down_revision: Union[str, None] = '3781e22d8b01' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -24,35 +24,33 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.create_table( - "credit", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("credit", sa.Numeric(precision=24, scale=12), nullable=True), - sa.Column("updated_at", sa.BigInteger(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id"), + 'credit', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('credit', sa.Numeric(precision=24, scale=12), nullable=True), + sa.Column('updated_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id'), ) - op.add_column("model", sa.Column("price", sa.JSON(), nullable=True)) + op.add_column('model', sa.Column('price', sa.JSON(), nullable=True)) op.create_table( - "credit_log", - sa.Column("id", sa.String(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column("credit", sa.Numeric(precision=24, scale=12), nullable=True), - sa.Column("detail", sa.JSON(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_credit_log_user_id"), "credit_log", ["user_id"], unique=False + 'credit_log', + sa.Column('id', sa.String(), nullable=False), + sa.Column('user_id', sa.String(), nullable=False), + sa.Column('credit', sa.Numeric(precision=24, scale=12), nullable=True), + sa.Column('detail', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint('id'), ) + op.create_index(op.f('ix_credit_log_user_id'), 'credit_log', ['user_id'], unique=False) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("credit") - op.drop_column("model", "price") - op.drop_index(op.f("ix_credit_log_user_id"), table_name="credit_log") - op.drop_table("credit_log") + op.drop_table('credit') + op.drop_column('model', 'price') + op.drop_index(op.f('ix_credit_log_user_id'), table_name='credit_log') + op.drop_table('credit_log') # ### end Alembic commands ### diff --git a/backend/open_webui/migrations/versions/af906e964978_add_feedback_table.py b/backend/open_webui/migrations/versions/af906e964978_add_feedback_table.py index 9116aa3884..4d8fd63e80 100644 --- a/backend/open_webui/migrations/versions/af906e964978_add_feedback_table.py +++ b/backend/open_webui/migrations/versions/af906e964978_add_feedback_table.py @@ -10,8 +10,8 @@ import sqlalchemy as sa # Revision identifiers, used by Alembic. -revision = "af906e964978" -down_revision = "c29facfe716b" +revision = 'af906e964978' +down_revision = 'c29facfe716b' branch_labels = None depends_on = None @@ -19,33 +19,23 @@ def upgrade(): # ### Create feedback table ### op.create_table( - "feedback", + 'feedback', + sa.Column('id', sa.Text(), primary_key=True), # Unique identifier for each feedback (TEXT type) + sa.Column('user_id', sa.Text(), nullable=True), # ID of the user providing the feedback (TEXT type) + sa.Column('version', sa.BigInteger(), default=0), # Version of feedback (BIGINT type) + sa.Column('type', sa.Text(), nullable=True), # Type of feedback (TEXT type) + sa.Column('data', sa.JSON(), nullable=True), # Feedback data (JSON type) + sa.Column('meta', sa.JSON(), nullable=True), # Metadata for feedback (JSON type) + sa.Column('snapshot', sa.JSON(), nullable=True), # snapshot data for feedback (JSON type) sa.Column( - "id", sa.Text(), primary_key=True - ), # Unique identifier for each feedback (TEXT type) - sa.Column( - "user_id", sa.Text(), nullable=True - ), # ID of the user providing the feedback (TEXT type) - sa.Column( - "version", sa.BigInteger(), default=0 - ), # Version of feedback (BIGINT type) - sa.Column("type", sa.Text(), nullable=True), # Type of feedback (TEXT type) - sa.Column("data", sa.JSON(), nullable=True), # Feedback data (JSON type) - sa.Column( - "meta", sa.JSON(), nullable=True - ), # Metadata for feedback (JSON type) - sa.Column( - "snapshot", sa.JSON(), nullable=True - ), # snapshot data for feedback (JSON type) - sa.Column( - "created_at", sa.BigInteger(), nullable=False + 'created_at', sa.BigInteger(), nullable=False ), # Feedback creation timestamp (BIGINT representing epoch) sa.Column( - "updated_at", sa.BigInteger(), nullable=False + 'updated_at', sa.BigInteger(), nullable=False ), # Feedback update timestamp (BIGINT representing epoch) ) def downgrade(): # ### Drop feedback table ### - op.drop_table("feedback") + op.drop_table('feedback') diff --git a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py index 0472c08616..623289d885 100644 --- a/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py +++ b/backend/open_webui/migrations/versions/b10670c03dd5_update_user_table.py @@ -17,8 +17,8 @@ import time # revision identifiers, used by Alembic. -revision: str = "b10670c03dd5" -down_revision: Union[str, None] = "2f1211949ecc" +revision: str = 'b10670c03dd5' +down_revision: Union[str, None] = '2f1211949ecc' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -33,13 +33,11 @@ def _drop_sqlite_indexes_for_column(table_name, column_name, conn): for idx in indexes: index_name = idx[1] # index name # Get indexed columns - idx_info = conn.execute( - sa.text(f"PRAGMA index_info('{index_name}')") - ).fetchall() + idx_info = conn.execute(sa.text(f"PRAGMA index_info('{index_name}')")).fetchall() indexed_cols = [row[2] for row in idx_info] # col names if column_name in indexed_cols: - conn.execute(sa.text(f"DROP INDEX IF EXISTS {index_name}")) + conn.execute(sa.text(f'DROP INDEX IF EXISTS {index_name}')) def _convert_column_to_json(table: str, column: str): @@ -47,9 +45,9 @@ def _convert_column_to_json(table: str, column: str): dialect = conn.dialect.name # SQLite cannot ALTER COLUMN โ†’ must recreate column - if dialect == "sqlite": + if dialect == 'sqlite': # 1. Add temporary column - op.add_column(table, sa.Column(f"{column}_json", sa.JSON(), nullable=True)) + op.add_column(table, sa.Column(f'{column}_json', sa.JSON(), nullable=True)) # 2. Load old data rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall() @@ -66,14 +64,14 @@ def _convert_column_to_json(table: str, column: str): conn.execute( sa.text(f'UPDATE "{table}" SET {column}_json = :val WHERE id = :id'), - {"val": json.dumps(parsed) if parsed else None, "id": uid}, + {'val': json.dumps(parsed) if parsed else None, 'id': uid}, ) # 3. Drop old TEXT column op.drop_column(table, column) # 4. Rename new JSON column โ†’ original name - op.alter_column(table, f"{column}_json", new_column_name=column) + op.alter_column(table, f'{column}_json', new_column_name=column) else: # PostgreSQL supports direct CAST @@ -81,7 +79,7 @@ def _convert_column_to_json(table: str, column: str): table, column, type_=sa.JSON(), - postgresql_using=f"{column}::json", + postgresql_using=f'{column}::json', ) @@ -89,85 +87,77 @@ def _convert_column_to_text(table: str, column: str): conn = op.get_bind() dialect = conn.dialect.name - if dialect == "sqlite": - op.add_column(table, sa.Column(f"{column}_text", sa.Text(), nullable=True)) + if dialect == 'sqlite': + op.add_column(table, sa.Column(f'{column}_text', sa.Text(), nullable=True)) rows = conn.execute(sa.text(f'SELECT id, {column} FROM "{table}"')).fetchall() for uid, raw in rows: conn.execute( sa.text(f'UPDATE "{table}" SET {column}_text = :val WHERE id = :id'), - {"val": json.dumps(raw) if raw else None, "id": uid}, + {'val': json.dumps(raw) if raw else None, 'id': uid}, ) op.drop_column(table, column) - op.alter_column(table, f"{column}_text", new_column_name=column) + op.alter_column(table, f'{column}_text', new_column_name=column) else: op.alter_column( table, column, type_=sa.Text(), - postgresql_using=f"to_json({column})::text", + postgresql_using=f'to_json({column})::text', ) def upgrade() -> None: - op.add_column( - "user", sa.Column("profile_banner_image_url", sa.Text(), nullable=True) - ) - op.add_column("user", sa.Column("timezone", sa.String(), nullable=True)) + op.add_column('user', sa.Column('profile_banner_image_url', sa.Text(), nullable=True)) + op.add_column('user', sa.Column('timezone', sa.String(), nullable=True)) - op.add_column("user", sa.Column("presence_state", sa.String(), nullable=True)) - op.add_column("user", sa.Column("status_emoji", sa.String(), nullable=True)) - op.add_column("user", sa.Column("status_message", sa.Text(), nullable=True)) - op.add_column( - "user", sa.Column("status_expires_at", sa.BigInteger(), nullable=True) - ) + op.add_column('user', sa.Column('presence_state', sa.String(), nullable=True)) + op.add_column('user', sa.Column('status_emoji', sa.String(), nullable=True)) + op.add_column('user', sa.Column('status_message', sa.Text(), nullable=True)) + op.add_column('user', sa.Column('status_expires_at', sa.BigInteger(), nullable=True)) - op.add_column("user", sa.Column("oauth", sa.JSON(), nullable=True)) + op.add_column('user', sa.Column('oauth', sa.JSON(), nullable=True)) # Convert info (TEXT/JSONField) โ†’ JSON - _convert_column_to_json("user", "info") + _convert_column_to_json('user', 'info') # Convert settings (TEXT/JSONField) โ†’ JSON - _convert_column_to_json("user", "settings") + _convert_column_to_json('user', 'settings') op.create_table( - "api_key", - sa.Column("id", sa.Text(), primary_key=True, unique=True), - sa.Column("user_id", sa.Text(), sa.ForeignKey("user.id", ondelete="CASCADE")), - sa.Column("key", sa.Text(), unique=True, nullable=False), - sa.Column("data", sa.JSON(), nullable=True), - sa.Column("expires_at", sa.BigInteger(), nullable=True), - sa.Column("last_used_at", sa.BigInteger(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=False), + 'api_key', + sa.Column('id', sa.Text(), primary_key=True, unique=True), + sa.Column('user_id', sa.Text(), sa.ForeignKey('user.id', ondelete='CASCADE')), + sa.Column('key', sa.Text(), unique=True, nullable=False), + sa.Column('data', sa.JSON(), nullable=True), + sa.Column('expires_at', sa.BigInteger(), nullable=True), + sa.Column('last_used_at', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), ) conn = op.get_bind() - users = conn.execute( - sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL') - ).fetchall() + users = conn.execute(sa.text('SELECT id, oauth_sub FROM "user" WHERE oauth_sub IS NOT NULL')).fetchall() for uid, oauth_sub in users: if oauth_sub: # Example formats supported: # provider@sub # plain sub (stored as {"oidc": {"sub": sub}}) - if "@" in oauth_sub: - provider, sub = oauth_sub.split("@", 1) + if '@' in oauth_sub: + provider, sub = oauth_sub.split('@', 1) else: - provider, sub = "oidc", oauth_sub + provider, sub = 'oidc', oauth_sub - oauth_json = json.dumps({provider: {"sub": sub}}) + oauth_json = json.dumps({provider: {'sub': sub}}) conn.execute( sa.text('UPDATE "user" SET oauth = :oauth WHERE id = :id'), - {"oauth": oauth_json, "id": uid}, + {'oauth': oauth_json, 'id': uid}, ) - users_with_keys = conn.execute( - sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL') - ).fetchall() + users_with_keys = conn.execute(sa.text('SELECT id, api_key FROM "user" WHERE api_key IS NOT NULL')).fetchall() now = int(time.time()) for uid, api_key in users_with_keys: @@ -178,72 +168,70 @@ def upgrade() -> None: VALUES (:id, :user_id, :key, :created_at, :updated_at) """), { - "id": f"key_{uid}", - "user_id": uid, - "key": api_key, - "created_at": now, - "updated_at": now, + 'id': f'key_{uid}', + 'user_id': uid, + 'key': api_key, + 'created_at': now, + 'updated_at': now, }, ) - if conn.dialect.name == "sqlite": - _drop_sqlite_indexes_for_column("user", "api_key", conn) - _drop_sqlite_indexes_for_column("user", "oauth_sub", conn) + if conn.dialect.name == 'sqlite': + _drop_sqlite_indexes_for_column('user', 'api_key', conn) + _drop_sqlite_indexes_for_column('user', 'oauth_sub', conn) - with op.batch_alter_table("user") as batch_op: - batch_op.drop_column("api_key") - batch_op.drop_column("oauth_sub") + with op.batch_alter_table('user') as batch_op: + batch_op.drop_column('api_key') + batch_op.drop_column('oauth_sub') def downgrade() -> None: # --- 1. Restore old oauth_sub column --- - op.add_column("user", sa.Column("oauth_sub", sa.Text(), nullable=True)) + op.add_column('user', sa.Column('oauth_sub', sa.Text(), nullable=True)) conn = op.get_bind() - users = conn.execute( - sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL') - ).fetchall() + users = conn.execute(sa.text('SELECT id, oauth FROM "user" WHERE oauth IS NOT NULL')).fetchall() for uid, oauth in users: try: data = json.loads(oauth) provider = list(data.keys())[0] - sub = data[provider].get("sub") - oauth_sub = f"{provider}@{sub}" + sub = data[provider].get('sub') + oauth_sub = f'{provider}@{sub}' except Exception: oauth_sub = None conn.execute( sa.text('UPDATE "user" SET oauth_sub = :oauth_sub WHERE id = :id'), - {"oauth_sub": oauth_sub, "id": uid}, + {'oauth_sub': oauth_sub, 'id': uid}, ) - op.drop_column("user", "oauth") + op.drop_column('user', 'oauth') # --- 2. Restore api_key field --- - op.add_column("user", sa.Column("api_key", sa.String(), nullable=True)) + op.add_column('user', sa.Column('api_key', sa.String(), nullable=True)) # Restore values from api_key - keys = conn.execute(sa.text("SELECT user_id, key FROM api_key")).fetchall() + keys = conn.execute(sa.text('SELECT user_id, key FROM api_key')).fetchall() for uid, key in keys: conn.execute( sa.text('UPDATE "user" SET api_key = :key WHERE id = :id'), - {"key": key, "id": uid}, + {'key': key, 'id': uid}, ) # Drop new table - op.drop_table("api_key") + op.drop_table('api_key') - with op.batch_alter_table("user") as batch_op: - batch_op.drop_column("profile_banner_image_url") - batch_op.drop_column("timezone") + with op.batch_alter_table('user') as batch_op: + batch_op.drop_column('profile_banner_image_url') + batch_op.drop_column('timezone') - batch_op.drop_column("presence_state") - batch_op.drop_column("status_emoji") - batch_op.drop_column("status_message") - batch_op.drop_column("status_expires_at") + batch_op.drop_column('presence_state') + batch_op.drop_column('status_emoji') + batch_op.drop_column('status_message') + batch_op.drop_column('status_expires_at') # Convert info (JSON) โ†’ TEXT - _convert_column_to_text("user", "info") + _convert_column_to_text('user', 'info') # Convert settings (JSON) โ†’ TEXT - _convert_column_to_text("user", "settings") + _convert_column_to_text('user', 'settings') diff --git a/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py b/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py index e8bf9a850f..e3668d3b6e 100644 --- a/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py +++ b/backend/open_webui/migrations/versions/b2c3d4e5f6a7_add_scim_column_to_user_table.py @@ -12,15 +12,15 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = "b2c3d4e5f6a7" -down_revision: Union[str, None] = "a1b2c3d4e5f6" +revision: str = 'b2c3d4e5f6a7' +down_revision: Union[str, None] = 'a1b2c3d4e5f6' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - op.add_column("user", sa.Column("scim", sa.JSON(), nullable=True)) + op.add_column('user', sa.Column('scim', sa.JSON(), nullable=True)) def downgrade() -> None: - op.drop_column("user", "scim") + op.drop_column('user', 'scim') diff --git a/backend/open_webui/migrations/versions/c0fbf31ca0db_update_file_table.py b/backend/open_webui/migrations/versions/c0fbf31ca0db_update_file_table.py index 5f7f2abf70..709b644150 100644 --- a/backend/open_webui/migrations/versions/c0fbf31ca0db_update_file_table.py +++ b/backend/open_webui/migrations/versions/c0fbf31ca0db_update_file_table.py @@ -12,21 +12,21 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = "c0fbf31ca0db" -down_revision: Union[str, None] = "ca81bd47c050" +revision: str = 'c0fbf31ca0db' +down_revision: Union[str, None] = 'ca81bd47c050' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.add_column("file", sa.Column("hash", sa.Text(), nullable=True)) - op.add_column("file", sa.Column("data", sa.JSON(), nullable=True)) - op.add_column("file", sa.Column("updated_at", sa.BigInteger(), nullable=True)) + op.add_column('file', sa.Column('hash', sa.Text(), nullable=True)) + op.add_column('file', sa.Column('data', sa.JSON(), nullable=True)) + op.add_column('file', sa.Column('updated_at', sa.BigInteger(), nullable=True)) def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_column("file", "updated_at") - op.drop_column("file", "data") - op.drop_column("file", "hash") + op.drop_column('file', 'updated_at') + op.drop_column('file', 'data') + op.drop_column('file', 'hash') diff --git a/backend/open_webui/migrations/versions/c29facfe716b_update_file_table_path.py b/backend/open_webui/migrations/versions/c29facfe716b_update_file_table_path.py index 7786de425f..37fe63ef15 100644 --- a/backend/open_webui/migrations/versions/c29facfe716b_update_file_table_path.py +++ b/backend/open_webui/migrations/versions/c29facfe716b_update_file_table_path.py @@ -12,35 +12,33 @@ from sqlalchemy.sql import table, column from sqlalchemy import String, Text, JSON, and_ -revision = "c29facfe716b" -down_revision = "c69f45358db4" +revision = 'c29facfe716b' +down_revision = 'c69f45358db4' branch_labels = None depends_on = None def upgrade(): # 1. Add the `path` column to the "file" table. - op.add_column("file", sa.Column("path", sa.Text(), nullable=True)) + op.add_column('file', sa.Column('path', sa.Text(), nullable=True)) # 2. Convert the `meta` column from Text/JSONField to `JSON()` # Use Alembic's default batch_op for dialect compatibility. - with op.batch_alter_table("file", schema=None) as batch_op: + with op.batch_alter_table('file', schema=None) as batch_op: batch_op.alter_column( - "meta", + 'meta', type_=sa.JSON(), existing_type=sa.Text(), existing_nullable=True, nullable=True, - postgresql_using="meta::json", + postgresql_using='meta::json', ) # 3. Migrate legacy data from `meta` JSONField # Fetch and process `meta` data from the table, add values to the new `path` column as necessary. # We will use SQLAlchemy core bindings to ensure safety across different databases. - file_table = table( - "file", column("id", String), column("meta", JSON), column("path", Text) - ) + file_table = table('file', column('id', String), column('meta', JSON), column('path', Text)) # Create connection to the database connection = op.get_bind() @@ -55,24 +53,18 @@ def upgrade(): # Iterate over each row to extract and update the `path` from `meta` column for row in results: - if "path" in row.meta: + if 'path' in row.meta: # Extract the `path` field from the `meta` JSON - path = row.meta.get("path") + path = row.meta.get('path') # Update the `file` table with the new `path` value - connection.execute( - file_table.update() - .where(file_table.c.id == row.id) - .values({"path": path}) - ) + connection.execute(file_table.update().where(file_table.c.id == row.id).values({'path': path})) def downgrade(): # 1. Remove the `path` column - op.drop_column("file", "path") + op.drop_column('file', 'path') # 2. Revert the `meta` column back to Text/JSONField - with op.batch_alter_table("file", schema=None) as batch_op: - batch_op.alter_column( - "meta", type_=sa.Text(), existing_type=sa.JSON(), existing_nullable=True - ) + with op.batch_alter_table('file', schema=None) as batch_op: + batch_op.alter_column('meta', type_=sa.Text(), existing_type=sa.JSON(), existing_nullable=True) diff --git a/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py b/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py index fa818e1f08..0eae928b91 100644 --- a/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py +++ b/backend/open_webui/migrations/versions/c440947495f3_add_chat_file_table.py @@ -12,45 +12,43 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision: str = "c440947495f3" -down_revision: Union[str, None] = "81cc2ce44d79" +revision: str = 'c440947495f3' +down_revision: Union[str, None] = '81cc2ce44d79' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: op.create_table( - "chat_file", - sa.Column("id", sa.Text(), primary_key=True), - sa.Column("user_id", sa.Text(), nullable=False), + 'chat_file', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('user_id', sa.Text(), nullable=False), sa.Column( - "chat_id", + 'chat_id', sa.Text(), - sa.ForeignKey("chat.id", ondelete="CASCADE"), + sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False, ), sa.Column( - "file_id", + 'file_id', sa.Text(), - sa.ForeignKey("file.id", ondelete="CASCADE"), + sa.ForeignKey('file.id', ondelete='CASCADE'), nullable=False, ), - sa.Column("message_id", sa.Text(), nullable=True), - sa.Column("created_at", sa.BigInteger(), nullable=False), - sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.Column('message_id', sa.Text(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), # indexes - sa.Index("ix_chat_file_chat_id", "chat_id"), - sa.Index("ix_chat_file_file_id", "file_id"), - sa.Index("ix_chat_file_message_id", "message_id"), - sa.Index("ix_chat_file_user_id", "user_id"), + sa.Index('ix_chat_file_chat_id', 'chat_id'), + sa.Index('ix_chat_file_file_id', 'file_id'), + sa.Index('ix_chat_file_message_id', 'message_id'), + sa.Index('ix_chat_file_user_id', 'user_id'), # unique constraints - sa.UniqueConstraint( - "chat_id", "file_id", name="uq_chat_file_chat_file" - ), # prevent duplicate entries + sa.UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'), # prevent duplicate entries ) pass def downgrade() -> None: - op.drop_table("chat_file") + op.drop_table('chat_file') pass diff --git a/backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py b/backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py index 83e0dc28ed..c9572fe7a3 100644 --- a/backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py +++ b/backend/open_webui/migrations/versions/c69f45358db4_add_folder_table.py @@ -9,42 +9,40 @@ from alembic import op import sqlalchemy as sa -revision = "c69f45358db4" -down_revision = "3ab32c4b8f59" +revision = 'c69f45358db4' +down_revision = '3ab32c4b8f59' branch_labels = None depends_on = None def upgrade(): op.create_table( - "folder", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("parent_id", sa.Text(), nullable=True), - sa.Column("user_id", sa.Text(), nullable=False), - sa.Column("name", sa.Text(), nullable=False), - sa.Column("items", sa.JSON(), nullable=True), - sa.Column("meta", sa.JSON(), nullable=True), - sa.Column("is_expanded", sa.Boolean(), default=False, nullable=False), + 'folder', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('parent_id', sa.Text(), nullable=True), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('items', sa.JSON(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('is_expanded', sa.Boolean(), default=False, nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False), sa.Column( - "created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False - ), - sa.Column( - "updated_at", + 'updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now(), ), - sa.PrimaryKeyConstraint("id", "user_id"), + sa.PrimaryKeyConstraint('id', 'user_id'), ) op.add_column( - "chat", - sa.Column("folder_id", sa.Text(), nullable=True), + 'chat', + sa.Column('folder_id', sa.Text(), nullable=True), ) def downgrade(): - op.drop_column("chat", "folder_id") + op.drop_column('chat', 'folder_id') - op.drop_table("folder") + op.drop_table('folder') diff --git a/backend/open_webui/migrations/versions/ca81bd47c050_add_config_table.py b/backend/open_webui/migrations/versions/ca81bd47c050_add_config_table.py index 1540aa6a7f..5fdf933dd6 100644 --- a/backend/open_webui/migrations/versions/ca81bd47c050_add_config_table.py +++ b/backend/open_webui/migrations/versions/ca81bd47c050_add_config_table.py @@ -12,23 +12,21 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = "ca81bd47c050" -down_revision: Union[str, None] = "7e5b5dc7342b" +revision: str = 'ca81bd47c050' +down_revision: Union[str, None] = '7e5b5dc7342b' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade(): op.create_table( - "config", - sa.Column("id", sa.Integer, primary_key=True), - sa.Column("data", sa.JSON(), nullable=False), - sa.Column("version", sa.Integer, nullable=False), + 'config', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('data', sa.JSON(), nullable=False), + sa.Column('version', sa.Integer, nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), sa.Column( - "created_at", sa.DateTime(), nullable=False, server_default=sa.func.now() - ), - sa.Column( - "updated_at", + 'updated_at', sa.DateTime(), nullable=True, server_default=sa.func.now(), @@ -38,4 +36,4 @@ def upgrade(): def downgrade(): - op.drop_table("config") + op.drop_table('config') diff --git a/backend/open_webui/migrations/versions/d31026856c01_update_folder_table_data.py b/backend/open_webui/migrations/versions/d31026856c01_update_folder_table_data.py index 3c916964e9..444e131db7 100644 --- a/backend/open_webui/migrations/versions/d31026856c01_update_folder_table_data.py +++ b/backend/open_webui/migrations/versions/d31026856c01_update_folder_table_data.py @@ -9,15 +9,15 @@ from alembic import op import sqlalchemy as sa -revision = "d31026856c01" -down_revision = "9f0c9cd09105" +revision = 'd31026856c01' +down_revision = '9f0c9cd09105' branch_labels = None depends_on = None def upgrade(): - op.add_column("folder", sa.Column("data", sa.JSON(), nullable=True)) + op.add_column('folder', sa.Column('data', sa.JSON(), nullable=True)) def downgrade(): - op.drop_column("folder", "data") + op.drop_column('folder', 'data') diff --git a/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py b/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py index 5569718dd8..5ed572cf7a 100644 --- a/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py +++ b/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py @@ -20,8 +20,8 @@ from open_webui.migrations.util import get_existing_tables -revision: str = "f1e2d3c4b5a6" -down_revision: Union[str, None] = "8452d01d26d7" +revision: str = 'f1e2d3c4b5a6' +down_revision: Union[str, None] = '8452d01d26d7' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -30,34 +30,34 @@ def upgrade() -> None: existing_tables = set(get_existing_tables()) # Create access_grant table - if "access_grant" not in existing_tables: + if 'access_grant' not in existing_tables: op.create_table( - "access_grant", - sa.Column("id", sa.Text(), nullable=False, primary_key=True), - sa.Column("resource_type", sa.Text(), nullable=False), - sa.Column("resource_id", sa.Text(), nullable=False), - sa.Column("principal_type", sa.Text(), nullable=False), - sa.Column("principal_id", sa.Text(), nullable=False), - sa.Column("permission", sa.Text(), nullable=False), - sa.Column("created_at", sa.BigInteger(), nullable=False), + 'access_grant', + sa.Column('id', sa.Text(), nullable=False, primary_key=True), + sa.Column('resource_type', sa.Text(), nullable=False), + sa.Column('resource_id', sa.Text(), nullable=False), + sa.Column('principal_type', sa.Text(), nullable=False), + sa.Column('principal_id', sa.Text(), nullable=False), + sa.Column('permission', sa.Text(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), sa.UniqueConstraint( - "resource_type", - "resource_id", - "principal_type", - "principal_id", - "permission", - name="uq_access_grant_grant", + 'resource_type', + 'resource_id', + 'principal_type', + 'principal_id', + 'permission', + name='uq_access_grant_grant', ), ) op.create_index( - "idx_access_grant_resource", - "access_grant", - ["resource_type", "resource_id"], + 'idx_access_grant_resource', + 'access_grant', + ['resource_type', 'resource_id'], ) op.create_index( - "idx_access_grant_principal", - "access_grant", - ["principal_type", "principal_id"], + 'idx_access_grant_principal', + 'access_grant', + ['principal_type', 'principal_id'], ) # Backfill existing access_control JSON data @@ -65,13 +65,13 @@ def upgrade() -> None: # Tables with access_control JSON columns: (table_name, resource_type) resource_tables = [ - ("knowledge", "knowledge"), - ("prompt", "prompt"), - ("tool", "tool"), - ("model", "model"), - ("note", "note"), - ("channel", "channel"), - ("file", "file"), + ('knowledge', 'knowledge'), + ('prompt', 'prompt'), + ('tool', 'tool'), + ('model', 'model'), + ('note', 'note'), + ('channel', 'channel'), + ('file', 'file'), ] now = int(time.time()) @@ -83,9 +83,7 @@ def upgrade() -> None: # Query all rows try: - result = conn.execute( - sa.text(f'SELECT id, access_control FROM "{table_name}"') - ) + result = conn.execute(sa.text(f'SELECT id, access_control FROM "{table_name}"')) rows = result.fetchall() except Exception: continue @@ -99,19 +97,16 @@ def upgrade() -> None: # EXCEPTION: files with NULL are PRIVATE (owner-only), not public is_null = ( access_control_json is None - or access_control_json == "null" - or ( - isinstance(access_control_json, str) - and access_control_json.strip().lower() == "null" - ) + or access_control_json == 'null' + or (isinstance(access_control_json, str) and access_control_json.strip().lower() == 'null') ) if is_null: # Files: NULL = private (no entry needed, owner has implicit access) # Other resources: NULL = public (insert user:* for read) - if resource_type == "file": + if resource_type == 'file': continue # Private - no entry needed - key = (resource_type, resource_id, "user", "*", "read") + key = (resource_type, resource_id, 'user', '*', 'read') if key not in inserted: try: conn.execute( @@ -120,13 +115,13 @@ def upgrade() -> None: VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at) """), { - "id": str(uuid.uuid4()), - "resource_type": resource_type, - "resource_id": resource_id, - "principal_type": "user", - "principal_id": "*", - "permission": "read", - "created_at": now, + 'id': str(uuid.uuid4()), + 'resource_type': resource_type, + 'resource_id': resource_id, + 'principal_type': 'user', + 'principal_id': '*', + 'permission': 'read', + 'created_at': now, }, ) inserted.add(key) @@ -149,28 +144,24 @@ def upgrade() -> None: continue # Check if it's effectively empty (no read/write keys with content) - read_data = access_control_json.get("read", {}) - write_data = access_control_json.get("write", {}) + read_data = access_control_json.get('read', {}) + write_data = access_control_json.get('write', {}) - has_read_grants = read_data.get("group_ids", []) or read_data.get( - "user_ids", [] - ) - has_write_grants = write_data.get("group_ids", []) or write_data.get( - "user_ids", [] - ) + has_read_grants = read_data.get('group_ids', []) or read_data.get('user_ids', []) + has_write_grants = write_data.get('group_ids', []) or write_data.get('user_ids', []) if not has_read_grants and not has_write_grants: # Empty permissions = private, no grants needed continue # Extract permissions and insert into access_grant table - for permission in ["read", "write"]: + for permission in ['read', 'write']: perm_data = access_control_json.get(permission, {}) if not perm_data: continue - for group_id in perm_data.get("group_ids", []): - key = (resource_type, resource_id, "group", group_id, permission) + for group_id in perm_data.get('group_ids', []): + key = (resource_type, resource_id, 'group', group_id, permission) if key in inserted: continue try: @@ -180,21 +171,21 @@ def upgrade() -> None: VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at) """), { - "id": str(uuid.uuid4()), - "resource_type": resource_type, - "resource_id": resource_id, - "principal_type": "group", - "principal_id": group_id, - "permission": permission, - "created_at": now, + 'id': str(uuid.uuid4()), + 'resource_type': resource_type, + 'resource_id': resource_id, + 'principal_type': 'group', + 'principal_id': group_id, + 'permission': permission, + 'created_at': now, }, ) inserted.add(key) except Exception: pass - for user_id in perm_data.get("user_ids", []): - key = (resource_type, resource_id, "user", user_id, permission) + for user_id in perm_data.get('user_ids', []): + key = (resource_type, resource_id, 'user', user_id, permission) if key in inserted: continue try: @@ -204,13 +195,13 @@ def upgrade() -> None: VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at) """), { - "id": str(uuid.uuid4()), - "resource_type": resource_type, - "resource_id": resource_id, - "principal_type": "user", - "principal_id": user_id, - "permission": permission, - "created_at": now, + 'id': str(uuid.uuid4()), + 'resource_type': resource_type, + 'resource_id': resource_id, + 'principal_type': 'user', + 'principal_id': user_id, + 'permission': permission, + 'created_at': now, }, ) inserted.add(key) @@ -223,7 +214,7 @@ def upgrade() -> None: continue try: with op.batch_alter_table(table_name) as batch: - batch.drop_column("access_control") + batch.drop_column('access_control') except Exception: pass @@ -235,20 +226,20 @@ def downgrade() -> None: # Resource tables mapping: (table_name, resource_type) resource_tables = [ - ("knowledge", "knowledge"), - ("prompt", "prompt"), - ("tool", "tool"), - ("model", "model"), - ("note", "note"), - ("channel", "channel"), - ("file", "file"), + ('knowledge', 'knowledge'), + ('prompt', 'prompt'), + ('tool', 'tool'), + ('model', 'model'), + ('note', 'note'), + ('channel', 'channel'), + ('file', 'file'), ] # Step 1: Re-add access_control columns to resource tables for table_name, _ in resource_tables: try: with op.batch_alter_table(table_name) as batch: - batch.add_column(sa.Column("access_control", sa.JSON(), nullable=True)) + batch.add_column(sa.Column('access_control', sa.JSON(), nullable=True)) except Exception: pass @@ -262,7 +253,7 @@ def downgrade() -> None: FROM access_grant WHERE resource_type = :resource_type """), - {"resource_type": resource_type}, + {'resource_type': resource_type}, ) rows = result.fetchall() except Exception: @@ -278,49 +269,35 @@ def downgrade() -> None: if resource_id not in resource_grants: resource_grants[resource_id] = { - "is_public": False, - "read": {"group_ids": [], "user_ids": []}, - "write": {"group_ids": [], "user_ids": []}, + 'is_public': False, + 'read': {'group_ids': [], 'user_ids': []}, + 'write': {'group_ids': [], 'user_ids': []}, } # Handle public access (user:* for read) - if ( - principal_type == "user" - and principal_id == "*" - and permission == "read" - ): - resource_grants[resource_id]["is_public"] = True + if principal_type == 'user' and principal_id == '*' and permission == 'read': + resource_grants[resource_id]['is_public'] = True continue # Add to appropriate list - if permission in ["read", "write"]: - if principal_type == "group": - if ( - principal_id - not in resource_grants[resource_id][permission]["group_ids"] - ): - resource_grants[resource_id][permission]["group_ids"].append( - principal_id - ) - elif principal_type == "user": - if ( - principal_id - not in resource_grants[resource_id][permission]["user_ids"] - ): - resource_grants[resource_id][permission]["user_ids"].append( - principal_id - ) + if permission in ['read', 'write']: + if principal_type == 'group': + if principal_id not in resource_grants[resource_id][permission]['group_ids']: + resource_grants[resource_id][permission]['group_ids'].append(principal_id) + elif principal_type == 'user': + if principal_id not in resource_grants[resource_id][permission]['user_ids']: + resource_grants[resource_id][permission]['user_ids'].append(principal_id) # Step 3: Update each resource with reconstructed JSON for resource_id, grants in resource_grants.items(): - if grants["is_public"]: + if grants['is_public']: # Public = NULL access_control_value = None elif ( - not grants["read"]["group_ids"] - and not grants["read"]["user_ids"] - and not grants["write"]["group_ids"] - and not grants["write"]["user_ids"] + not grants['read']['group_ids'] + and not grants['read']['user_ids'] + and not grants['write']['group_ids'] + and not grants['write']['user_ids'] ): # No grants = should not happen (would mean no entries), default to {} access_control_value = json.dumps({}) @@ -328,17 +305,15 @@ def downgrade() -> None: # Custom permissions access_control_value = json.dumps( { - "read": grants["read"], - "write": grants["write"], + 'read': grants['read'], + 'write': grants['write'], } ) try: conn.execute( - sa.text( - f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id' - ), - {"access_control": access_control_value, "id": resource_id}, + sa.text(f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'), + {'access_control': access_control_value, 'id': resource_id}, ) except Exception: pass @@ -346,7 +321,7 @@ def downgrade() -> None: # Step 4: Set all resources WITHOUT entries to private # For files: NULL means private (owner-only), so leave as NULL # For other resources: {} means private, so update to {} - if resource_type != "file": + if resource_type != 'file': try: conn.execute( sa.text(f""" @@ -357,13 +332,13 @@ def downgrade() -> None: ) AND access_control IS NULL """), - {"private_value": json.dumps({}), "resource_type": resource_type}, + {'private_value': json.dumps({}), 'resource_type': resource_type}, ) except Exception: pass # For files, NULL stays NULL - no action needed # Step 5: Drop the access_grant table - op.drop_index("idx_access_grant_principal", table_name="access_grant") - op.drop_index("idx_access_grant_resource", table_name="access_grant") - op.drop_table("access_grant") + op.drop_index('idx_access_grant_principal', table_name='access_grant') + op.drop_index('idx_access_grant_resource', table_name='access_grant') + op.drop_table('access_grant') diff --git a/backend/open_webui/models/access_grants.py b/backend/open_webui/models/access_grants.py index 4519abc964..20601fd30e 100644 --- a/backend/open_webui/models/access_grants.py +++ b/backend/open_webui/models/access_grants.py @@ -19,28 +19,24 @@ class AccessGrant(Base): - __tablename__ = "access_grant" + __tablename__ = 'access_grant' id = Column(Text, primary_key=True) - resource_type = Column( - Text, nullable=False - ) # "knowledge", "model", "prompt", "tool", "note", "channel", "file" + 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_id = Column( - Text, nullable=False - ) # user_id, group_id, or "*" (wildcard for public) + 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) __table_args__ = ( UniqueConstraint( - "resource_type", - "resource_id", - "principal_type", - "principal_id", - "permission", - name="uq_access_grant_grant", + 'resource_type', + 'resource_id', + 'principal_type', + 'principal_id', + 'permission', + name='uq_access_grant_grant', ), ) @@ -66,7 +62,7 @@ class AccessGrantResponse(BaseModel): permission: str @classmethod - def from_grant(cls, grant: "AccessGrantModel") -> "AccessGrantResponse": + def from_grant(cls, grant: 'AccessGrantModel') -> 'AccessGrantResponse': return cls( id=grant.id, principal_type=grant.principal_type, @@ -100,14 +96,14 @@ def access_control_to_grants( if access_control is None: # NULL โ†’ public read (user:* for read) # Exception: files with NULL are private (owner-only), no grants needed - if resource_type != "file": + if resource_type != 'file': grants.append( { - "resource_type": resource_type, - "resource_id": resource_id, - "principal_type": "user", - "principal_id": "*", - "permission": "read", + 'resource_type': resource_type, + 'resource_id': resource_id, + 'principal_type': 'user', + 'principal_id': '*', + 'permission': 'read', } ) return grants @@ -117,30 +113,30 @@ def access_control_to_grants( return grants # Parse structured permissions - for permission in ["read", "write"]: + for permission in ['read', 'write']: perm_data = access_control.get(permission, {}) if not perm_data: continue - for group_id in perm_data.get("group_ids", []): + for group_id in perm_data.get('group_ids', []): grants.append( { - "resource_type": resource_type, - "resource_id": resource_id, - "principal_type": "group", - "principal_id": group_id, - "permission": permission, + 'resource_type': resource_type, + 'resource_id': resource_id, + 'principal_type': 'group', + 'principal_id': group_id, + 'permission': permission, } ) - for user_id in perm_data.get("user_ids", []): + for user_id in perm_data.get('user_ids', []): grants.append( { - "resource_type": resource_type, - "resource_id": resource_id, - "principal_type": "user", - "principal_id": user_id, - "permission": permission, + 'resource_type': resource_type, + 'resource_id': resource_id, + 'principal_type': 'user', + 'principal_id': user_id, + 'permission': permission, } ) @@ -164,27 +160,23 @@ def normalize_access_grants(access_grants: Optional[list]) -> list[dict]: if not isinstance(grant, dict): continue - principal_type = grant.get("principal_type") - principal_id = grant.get("principal_id") - permission = grant.get("permission") + principal_type = grant.get('principal_type') + principal_id = grant.get('principal_id') + permission = grant.get('permission') - if principal_type not in ("user", "group"): + if principal_type not in ('user', 'group'): continue - if permission not in ("read", "write"): + if permission not in ('read', 'write'): continue if not isinstance(principal_id, str) or not principal_id: continue key = (principal_type, principal_id, permission) deduped[key] = { - "id": ( - grant.get("id") - if isinstance(grant.get("id"), str) and grant.get("id") - else str(uuid.uuid4()) - ), - "principal_type": principal_type, - "principal_id": principal_id, - "permission": permission, + 'id': (grant.get('id') if isinstance(grant.get('id'), str) and grant.get('id') else str(uuid.uuid4())), + 'principal_type': principal_type, + 'principal_id': principal_id, + 'permission': permission, } return list(deduped.values()) @@ -195,11 +187,17 @@ 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'] == 'user' and grant['principal_id'] == '*' and grant['permission'] == 'read': + return True + return False + + +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': return True return False @@ -209,7 +207,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'] == 'user' and grant['principal_id'] != '*': return True return False @@ -225,18 +223,9 @@ def strip_user_access_grants(access_grants: Optional[list]) -> list: grant for grant in access_grants if not ( - ( - grant.get("principal_type") - if isinstance(grant, dict) - else getattr(grant, "principal_type", None) - ) - == "user" - and ( - grant.get("principal_id") - if isinstance(grant, dict) - else getattr(grant, "principal_id", None) - ) - != "*" + (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)) != '*' ) ] @@ -260,29 +249,25 @@ def grants_to_access_control(grants: list) -> Optional[dict]: return {} # No grants = private/owner-only result = { - "read": {"group_ids": [], "user_ids": []}, - "write": {"group_ids": [], "user_ids": []}, + 'read': {'group_ids': [], 'user_ids': []}, + 'write': {'group_ids': [], 'user_ids': []}, } is_public = False for grant in grants: - if ( - grant.principal_type == "user" - and grant.principal_id == "*" - and grant.permission == "read" - ): + if grant.principal_type == 'user' and grant.principal_id == '*' and grant.permission == 'read': is_public = True continue # Don't add wildcard to user_ids list - if grant.permission not in ("read", "write"): + if grant.permission not in ('read', 'write'): continue - if grant.principal_type == "group": - if grant.principal_id not in result[grant.permission]["group_ids"]: - result[grant.permission]["group_ids"].append(grant.principal_id) - elif grant.principal_type == "user": - if grant.principal_id not in result[grant.permission]["user_ids"]: - result[grant.permission]["user_ids"].append(grant.principal_id) + if grant.principal_type == 'group': + if grant.principal_id not in result[grant.permission]['group_ids']: + result[grant.permission]['group_ids'].append(grant.principal_id) + elif grant.principal_type == 'user': + if grant.principal_id not in result[grant.permission]['user_ids']: + result[grant.permission]['user_ids'].append(grant.principal_id) if is_public: return None # Public read access @@ -399,9 +384,7 @@ def set_access_control( ).delete() # Convert JSON to grant dicts - grant_dicts = access_control_to_grants( - resource_type, resource_id, access_control - ) + grant_dicts = access_control_to_grants(resource_type, resource_id, access_control) # Insert new grants results = [] @@ -442,9 +425,9 @@ def set_access_grants( id=str(uuid.uuid4()), resource_type=resource_type, resource_id=resource_id, - principal_type=grant_dict["principal_type"], - principal_id=grant_dict["principal_id"], - permission=grant_dict["permission"], + principal_type=grant_dict['principal_type'], + principal_id=grant_dict['principal_id'], + permission=grant_dict['permission'], created_at=int(time.time()), ) db.add(grant) @@ -511,9 +494,7 @@ def get_grants_by_resources( ) .all() ) - result: dict[str, list[AccessGrantModel]] = { - rid: [] for rid in resource_ids - } + result: dict[str, list[AccessGrantModel]] = {rid: [] for rid in resource_ids} for g in grants: result[g.resource_id].append(AccessGrantModel.model_validate(g)) return result @@ -523,7 +504,7 @@ def has_access( user_id: str, resource_type: str, resource_id: str, - permission: str = "read", + permission: str = 'read', user_group_ids: Optional[set[str]] = None, db: Optional[Session] = None, ) -> bool: @@ -540,12 +521,12 @@ def has_access( conditions = [ # Public access and_( - AccessGrant.principal_type == "user", - AccessGrant.principal_id == "*", + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', ), # Direct user access and_( - AccessGrant.principal_type == "user", + AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id, ), ] @@ -560,7 +541,7 @@ def has_access( if user_group_ids: conditions.append( and_( - AccessGrant.principal_type == "group", + AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(user_group_ids), ) ) @@ -582,7 +563,7 @@ def get_accessible_resource_ids( user_id: str, resource_type: str, resource_ids: list[str], - permission: str = "read", + permission: str = 'read', user_group_ids: Optional[set[str]] = None, db: Optional[Session] = None, ) -> set[str]: @@ -597,11 +578,11 @@ def get_accessible_resource_ids( with get_db_context(db) as db: conditions = [ and_( - AccessGrant.principal_type == "user", - AccessGrant.principal_id == "*", + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', ), and_( - AccessGrant.principal_type == "user", + AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id, ), ] @@ -615,7 +596,7 @@ def get_accessible_resource_ids( if user_group_ids: conditions.append( and_( - AccessGrant.principal_type == "group", + AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(user_group_ids), ) ) @@ -637,7 +618,7 @@ def get_users_with_access( self, resource_type: str, resource_id: str, - permission: str = "read", + permission: str = 'read', db: Optional[Session] = None, ) -> list: """ @@ -660,19 +641,17 @@ def get_users_with_access( # Check for public access for grant in grants: - if grant.principal_type == "user" and grant.principal_id == "*": - result = Users.get_users(filter={"roles": ["!pending"]}, db=db) - return result.get("users", []) + if grant.principal_type == 'user' and grant.principal_id == '*': + result = Users.get_users(filter={'roles': ['!pending']}, db=db) + return result.get('users', []) user_ids_with_access = set() for grant in grants: - if grant.principal_type == "user": + if grant.principal_type == 'user': user_ids_with_access.add(grant.principal_id) - elif grant.principal_type == "group": - group_user_ids = Groups.get_group_user_ids_by_id( - grant.principal_id, db=db - ) + elif grant.principal_type == 'group': + group_user_ids = Groups.get_group_user_ids_by_id(grant.principal_id, db=db) if group_user_ids: user_ids_with_access.update(group_user_ids) @@ -688,20 +667,18 @@ def has_permission_filter( DocumentModel, filter: dict, resource_type: str, - permission: str = "read", + permission: str = 'read', ): """ Apply access control filtering to a SQLAlchemy query by JOINing with access_grant. This replaces the old JSON-column-based filtering with a proper relational JOIN. """ - group_ids = filter.get("group_ids", []) - user_id = filter.get("user_id") + group_ids = filter.get('group_ids', []) + user_id = filter.get('user_id') - if permission == "read_only": - return self._has_read_only_permission_filter( - db, query, DocumentModel, filter, resource_type - ) + if permission == 'read_only': + return self._has_read_only_permission_filter(db, query, DocumentModel, filter, resource_type) # Build principal conditions principal_conditions = [] @@ -710,8 +687,8 @@ def has_permission_filter( # Public access: user:* read principal_conditions.append( and_( - AccessGrant.principal_type == "user", - AccessGrant.principal_id == "*", + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', ) ) @@ -722,7 +699,7 @@ def has_permission_filter( # Direct user grant principal_conditions.append( and_( - AccessGrant.principal_type == "user", + AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id, ) ) @@ -731,7 +708,7 @@ def has_permission_filter( # Group grants principal_conditions.append( and_( - AccessGrant.principal_type == "group", + AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(group_ids), ) ) @@ -751,13 +728,13 @@ def has_permission_filter( AccessGrant.permission == permission, or_( and_( - AccessGrant.principal_type == "user", - AccessGrant.principal_id == "*", + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', ), *( [ and_( - AccessGrant.principal_type == "user", + AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id, ) ] @@ -767,7 +744,7 @@ def has_permission_filter( *( [ and_( - AccessGrant.principal_type == "group", + AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(group_ids), ) ] @@ -800,8 +777,8 @@ def _has_read_only_permission_filter( Filter for items where user has read BUT NOT write access. Public items are NOT considered read_only. """ - group_ids = filter.get("group_ids", []) - user_id = filter.get("user_id") + group_ids = filter.get('group_ids', []) + user_id = filter.get('user_id') from sqlalchemy import exists as sa_exists, select @@ -811,12 +788,12 @@ def _has_read_only_permission_filter( .where( AccessGrant.resource_type == resource_type, AccessGrant.resource_id == DocumentModel.id, - AccessGrant.permission == "read", + AccessGrant.permission == 'read', or_( *( [ and_( - AccessGrant.principal_type == "user", + AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id, ) ] @@ -826,7 +803,7 @@ def _has_read_only_permission_filter( *( [ and_( - AccessGrant.principal_type == "group", + AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(group_ids), ) ] @@ -845,12 +822,12 @@ def _has_read_only_permission_filter( .where( AccessGrant.resource_type == resource_type, AccessGrant.resource_id == DocumentModel.id, - AccessGrant.permission == "write", + AccessGrant.permission == 'write', or_( *( [ and_( - AccessGrant.principal_type == "user", + AccessGrant.principal_type == 'user', AccessGrant.principal_id == user_id, ) ] @@ -860,7 +837,7 @@ def _has_read_only_permission_filter( *( [ and_( - AccessGrant.principal_type == "group", + AccessGrant.principal_type == 'group', AccessGrant.principal_id.in_(group_ids), ) ] @@ -879,9 +856,9 @@ def _has_read_only_permission_filter( .where( AccessGrant.resource_type == resource_type, AccessGrant.resource_id == DocumentModel.id, - AccessGrant.permission == "read", - AccessGrant.principal_type == "user", - AccessGrant.principal_id == "*", + AccessGrant.permission == 'read', + AccessGrant.principal_type == 'user', + AccessGrant.principal_id == '*', ) .correlate(DocumentModel) .exists() diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 25fb873b95..1a1b164c12 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -17,7 +17,7 @@ class Auth(Base): - __tablename__ = "auth" + __tablename__ = 'auth' id = Column(String, primary_key=True, unique=True) email = Column(String) @@ -73,9 +73,9 @@ class SignupForm(BaseModel): name: str email: str password: str - profile_image_url: Optional[str] = "/user.png" + profile_image_url: Optional[str] = '/user.png' - @field_validator("profile_image_url") + @field_validator('profile_image_url') @classmethod def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]: if v is not None: @@ -84,7 +84,7 @@ def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]: class AddUserForm(SignupForm): - role: Optional[str] = "pending" + role: Optional[str] = 'pending' class AuthsTable: @@ -93,25 +93,21 @@ def insert_new_auth( email: str, password: str, name: str, - profile_image_url: str = "/user.png", - role: str = "pending", + profile_image_url: str = '/user.png', + role: str = 'pending', oauth: Optional[dict] = None, db: Optional[Session] = None, ) -> Optional[UserModel]: with get_db_context(db) as db: - log.info("insert_new_auth") + log.info('insert_new_auth') id = str(uuid.uuid4()) - auth = AuthModel( - **{"id": id, "email": email, "password": password, "active": True} - ) + auth = AuthModel(**{'id': id, 'email': email, 'password': password, 'active': True}) result = Auth(**auth.model_dump()) db.add(result) - user = Users.insert_new_user( - id, name, email, profile_image_url, role, oauth=oauth, db=db - ) + user = Users.insert_new_user(id, name, email, profile_image_url, role, oauth=oauth, db=db) db.commit() db.refresh(result) @@ -124,7 +120,7 @@ def insert_new_auth( def authenticate_user( self, email: str, verify_password: callable, db: Optional[Session] = None ) -> Optional[UserModel]: - log.info(f"authenticate_user: {email}") + log.info(f'authenticate_user: {email}') user = Users.get_user_by_email(email, db=db) if not user: @@ -143,10 +139,8 @@ def authenticate_user( except Exception: return None - def authenticate_user_by_api_key( - self, api_key: str, db: Optional[Session] = None - ) -> Optional[UserModel]: - log.info(f"authenticate_user_by_api_key") + def authenticate_user_by_api_key(self, api_key: str, db: Optional[Session] = None) -> Optional[UserModel]: + log.info(f'authenticate_user_by_api_key') # if no api_key, return None if not api_key: return None @@ -157,10 +151,8 @@ def authenticate_user_by_api_key( except Exception: return False - def authenticate_user_by_email( - self, email: str, db: Optional[Session] = None - ) -> Optional[UserModel]: - log.info(f"authenticate_user_by_email: {email}") + def authenticate_user_by_email(self, email: str, db: Optional[Session] = None) -> Optional[UserModel]: + log.info(f'authenticate_user_by_email: {email}') try: with get_db_context(db) as db: # Single JOIN query instead of two separate queries @@ -177,28 +169,22 @@ def authenticate_user_by_email( except Exception: return None - def update_user_password_by_id( - self, id: str, new_password: str, db: Optional[Session] = None - ) -> bool: + def update_user_password_by_id(self, id: str, new_password: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - result = ( - db.query(Auth).filter_by(id=id).update({"password": new_password}) - ) + result = db.query(Auth).filter_by(id=id).update({'password': new_password}) db.commit() return True if result == 1 else False except Exception: return False - def update_email_by_id( - self, id: str, email: str, db: Optional[Session] = None - ) -> bool: + def update_email_by_id(self, id: str, email: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - result = db.query(Auth).filter_by(id=id).update({"email": email}) + result = db.query(Auth).filter_by(id=id).update({'email': email}) db.commit() if result == 1: - Users.update_user_by_id(id, {"email": email}, db=db) + Users.update_user_by_id(id, {'email': email}, db=db) return True return False except Exception: diff --git a/backend/open_webui/models/channels.py b/backend/open_webui/models/channels.py index e212789a44..4d773491d5 100644 --- a/backend/open_webui/models/channels.py +++ b/backend/open_webui/models/channels.py @@ -37,7 +37,7 @@ class Channel(Base): - __tablename__ = "channel" + __tablename__ = 'channel' id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) @@ -94,7 +94,7 @@ class ChannelModel(BaseModel): class ChannelMember(Base): - __tablename__ = "channel_member" + __tablename__ = 'channel_member' id = Column(Text, primary_key=True, unique=True) channel_id = Column(Text, nullable=False) @@ -154,25 +154,19 @@ class ChannelMemberModel(BaseModel): class ChannelFile(Base): - __tablename__ = "channel_file" + __tablename__ = 'channel_file' id = Column(Text, unique=True, primary_key=True) user_id = Column(Text, nullable=False) - channel_id = Column( - Text, ForeignKey("channel.id", ondelete="CASCADE"), nullable=False - ) - message_id = Column( - Text, ForeignKey("message.id", ondelete="CASCADE"), nullable=True - ) - file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False) + channel_id = Column(Text, ForeignKey('channel.id', ondelete='CASCADE'), nullable=False) + message_id = Column(Text, ForeignKey('message.id', ondelete='CASCADE'), nullable=True) + file_id = Column(Text, ForeignKey('file.id', ondelete='CASCADE'), nullable=False) created_at = Column(BigInteger, nullable=False) updated_at = Column(BigInteger, nullable=False) - __table_args__ = ( - UniqueConstraint("channel_id", "file_id", name="uq_channel_file_channel_file"), - ) + __table_args__ = (UniqueConstraint('channel_id', 'file_id', name='uq_channel_file_channel_file'),) class ChannelFileModel(BaseModel): @@ -189,7 +183,7 @@ class ChannelFileModel(BaseModel): class ChannelWebhook(Base): - __tablename__ = "channel_webhook" + __tablename__ = 'channel_webhook' id = Column(Text, primary_key=True, unique=True) channel_id = Column(Text, nullable=False) @@ -235,7 +229,7 @@ class ChannelResponse(ChannelModel): class ChannelForm(BaseModel): - name: str = "" + name: str = '' description: Optional[str] = None is_private: Optional[bool] = None data: Optional[dict] = None @@ -255,10 +249,8 @@ class ChannelWebhookForm(BaseModel): class ChannelTable: - def _get_access_grants( - self, channel_id: str, db: Optional[Session] = None - ) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource("channel", channel_id, db=db) + def _get_access_grants(self, channel_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource('channel', channel_id, db=db) def _to_channel_model( self, @@ -266,13 +258,9 @@ def _to_channel_model( access_grants: Optional[list[AccessGrantModel]] = None, db: Optional[Session] = None, ) -> ChannelModel: - channel_data = ChannelModel.model_validate(channel).model_dump( - exclude={"access_grants"} - ) - channel_data["access_grants"] = ( - access_grants - if access_grants is not None - else self._get_access_grants(channel_data["id"], db=db) + channel_data = ChannelModel.model_validate(channel).model_dump(exclude={'access_grants'}) + channel_data['access_grants'] = ( + access_grants if access_grants is not None else self._get_access_grants(channel_data['id'], db=db) ) return ChannelModel.model_validate(channel_data) @@ -313,20 +301,20 @@ def _create_membership_models( for uid in user_ids: model = ChannelMemberModel( **{ - "id": str(uuid.uuid4()), - "channel_id": channel_id, - "user_id": uid, - "status": "joined", - "is_active": True, - "is_channel_muted": False, - "is_channel_pinned": False, - "invited_at": now, - "invited_by": invited_by, - "joined_at": now, - "left_at": None, - "last_read_at": now, - "created_at": now, - "updated_at": now, + 'id': str(uuid.uuid4()), + 'channel_id': channel_id, + 'user_id': uid, + 'status': 'joined', + 'is_active': True, + 'is_channel_muted': False, + 'is_channel_pinned': False, + 'invited_at': now, + 'invited_by': invited_by, + 'joined_at': now, + 'left_at': None, + 'last_read_at': now, + 'created_at': now, + 'updated_at': now, } ) memberships.append(ChannelMember(**model.model_dump())) @@ -339,19 +327,19 @@ def insert_new_channel( with get_db_context(db) as db: channel = ChannelModel( **{ - **form_data.model_dump(exclude={"access_grants"}), - "type": form_data.type if form_data.type else None, - "name": form_data.name.lower(), - "id": str(uuid.uuid4()), - "user_id": user_id, - "created_at": int(time.time_ns()), - "updated_at": int(time.time_ns()), - "access_grants": [], + **form_data.model_dump(exclude={'access_grants'}), + 'type': form_data.type if form_data.type else None, + 'name': form_data.name.lower(), + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'created_at': int(time.time_ns()), + 'updated_at': int(time.time_ns()), + 'access_grants': [], } ) - new_channel = Channel(**channel.model_dump(exclude={"access_grants"})) + new_channel = Channel(**channel.model_dump(exclude={'access_grants'})) - if form_data.type in ["group", "dm"]: + if form_data.type in ['group', 'dm']: users = self._collect_unique_user_ids( invited_by=user_id, user_ids=form_data.user_ids, @@ -366,18 +354,14 @@ def insert_new_channel( db.add_all(memberships) db.add(new_channel) db.commit() - AccessGrants.set_access_grants( - "channel", new_channel.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('channel', new_channel.id, form_data.access_grants, db=db) return self._to_channel_model(new_channel, db=db) def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]: with get_db_context(db) as db: channels = db.query(Channel).all() channel_ids = [channel.id for channel in channels] - grants_map = AccessGrants.get_grants_by_resources( - "channel", channel_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) return [ self._to_channel_model( channel, @@ -387,23 +371,19 @@ def get_channels(self, db: Optional[Session] = None) -> list[ChannelModel]: for channel in channels ] - def _has_permission(self, db, query, filter: dict, permission: str = "read"): + def _has_permission(self, db, query, filter: dict, permission: str = 'read'): return AccessGrants.has_permission_filter( db=db, query=query, DocumentModel=Channel, filter=filter, - resource_type="channel", + resource_type='channel', permission=permission, ) - def get_channels_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[ChannelModel]: + def get_channels_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[ChannelModel]: with get_db_context(db) as db: - user_group_ids = [ - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - ] + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id, db=db)] membership_channels = ( db.query(Channel) @@ -411,7 +391,7 @@ def get_channels_by_user_id( .filter( Channel.deleted_at.is_(None), Channel.archived_at.is_(None), - Channel.type.in_(["group", "dm"]), + Channel.type.in_(['group', 'dm']), ChannelMember.user_id == user_id, ChannelMember.is_active.is_(True), ) @@ -423,29 +403,20 @@ def get_channels_by_user_id( Channel.archived_at.is_(None), or_( Channel.type.is_(None), # True NULL/None - Channel.type == "", # Empty string - and_(Channel.type != "group", Channel.type != "dm"), + Channel.type == '', # Empty string + and_(Channel.type != 'group', Channel.type != 'dm'), ), ) - query = self._has_permission( - db, query, {"user_id": user_id, "group_ids": user_group_ids} - ) + query = self._has_permission(db, query, {'user_id': user_id, 'group_ids': user_group_ids}) standard_channels = query.all() all_channels = membership_channels + standard_channels channel_ids = [c.id for c in all_channels] - grants_map = AccessGrants.get_grants_by_resources( - "channel", channel_ids, db=db - ) - return [ - self._to_channel_model(c, access_grants=grants_map.get(c.id, []), db=db) - for c in all_channels - ] + grants_map = AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) + return [self._to_channel_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in all_channels] - def get_dm_channel_by_user_ids( - self, user_ids: list[str], db: Optional[Session] = None - ) -> Optional[ChannelModel]: + def get_dm_channel_by_user_ids(self, user_ids: list[str], db: Optional[Session] = None) -> Optional[ChannelModel]: with get_db_context(db) as db: # Ensure uniqueness in case a list with duplicates is passed unique_user_ids = list(set(user_ids)) @@ -471,7 +442,7 @@ def get_dm_channel_by_user_ids( db.query(Channel) .filter( Channel.id.in_(subquery), - Channel.type == "dm", + Channel.type == 'dm', ) .first() ) @@ -488,32 +459,23 @@ def add_members_to_channel( ) -> list[ChannelMemberModel]: with get_db_context(db) as db: # 1. Collect all user_ids including groups + inviter - requested_users = self._collect_unique_user_ids( - invited_by, user_ids, group_ids - ) + requested_users = self._collect_unique_user_ids(invited_by, user_ids, group_ids) existing_users = { row.user_id - for row in db.query(ChannelMember.user_id) - .filter(ChannelMember.channel_id == channel_id) - .all() + for row in db.query(ChannelMember.user_id).filter(ChannelMember.channel_id == channel_id).all() } new_user_ids = requested_users - existing_users if not new_user_ids: return [] # Nothing to add - new_memberships = self._create_membership_models( - channel_id, invited_by, new_user_ids - ) + new_memberships = self._create_membership_models(channel_id, invited_by, new_user_ids) db.add_all(new_memberships) db.commit() - return [ - ChannelMemberModel.model_validate(membership) - for membership in new_memberships - ] + return [ChannelMemberModel.model_validate(membership) for membership in new_memberships] def remove_members_from_channel( self, @@ -533,9 +495,7 @@ def remove_members_from_channel( db.commit() return result # number of rows deleted - def is_user_channel_manager( - self, channel_id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def is_user_channel_manager(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: # Check if the user is the creator of the channel # or has a 'manager' role in ChannelMember @@ -548,15 +508,13 @@ def is_user_channel_manager( .filter( ChannelMember.channel_id == channel_id, ChannelMember.user_id == user_id, - ChannelMember.role == "manager", + ChannelMember.role == 'manager', ) .first() ) return membership is not None - def join_channel( - self, channel_id: str, user_id: str, db: Optional[Session] = None - ) -> Optional[ChannelMemberModel]: + def join_channel(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> Optional[ChannelMemberModel]: with get_db_context(db) as db: # Check if the membership already exists existing_membership = ( @@ -573,18 +531,18 @@ def join_channel( # Create new membership channel_member = ChannelMemberModel( **{ - "id": str(uuid.uuid4()), - "channel_id": channel_id, - "user_id": user_id, - "status": "joined", - "is_active": True, - "is_channel_muted": False, - "is_channel_pinned": False, - "joined_at": int(time.time_ns()), - "left_at": None, - "last_read_at": int(time.time_ns()), - "created_at": int(time.time_ns()), - "updated_at": int(time.time_ns()), + 'id': str(uuid.uuid4()), + 'channel_id': channel_id, + 'user_id': user_id, + 'status': 'joined', + 'is_active': True, + 'is_channel_muted': False, + 'is_channel_pinned': False, + 'joined_at': int(time.time_ns()), + 'left_at': None, + 'last_read_at': int(time.time_ns()), + 'created_at': int(time.time_ns()), + 'updated_at': int(time.time_ns()), } ) new_membership = ChannelMember(**channel_member.model_dump()) @@ -593,9 +551,7 @@ def join_channel( db.commit() return channel_member - def leave_channel( - self, channel_id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def leave_channel(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: membership = ( db.query(ChannelMember) @@ -608,7 +564,7 @@ def leave_channel( if not membership: return False - membership.status = "left" + membership.status = 'left' membership.is_active = False membership.left_at = int(time.time_ns()) membership.updated_at = int(time.time_ns()) @@ -630,19 +586,10 @@ def get_member_by_channel_and_user_id( ) return ChannelMemberModel.model_validate(membership) if membership else None - def get_members_by_channel_id( - self, channel_id: str, db: Optional[Session] = None - ) -> list[ChannelMemberModel]: + def get_members_by_channel_id(self, channel_id: str, db: Optional[Session] = None) -> list[ChannelMemberModel]: with get_db_context(db) as db: - memberships = ( - db.query(ChannelMember) - .filter(ChannelMember.channel_id == channel_id) - .all() - ) - return [ - ChannelMemberModel.model_validate(membership) - for membership in memberships - ] + memberships = db.query(ChannelMember).filter(ChannelMember.channel_id == channel_id).all() + return [ChannelMemberModel.model_validate(membership) for membership in memberships] def pin_channel( self, @@ -669,9 +616,7 @@ def pin_channel( db.commit() return True - def update_member_last_read_at( - self, channel_id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def update_member_last_read_at(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: membership = ( db.query(ChannelMember) @@ -715,9 +660,7 @@ def update_member_active_status( db.commit() return True - def is_user_channel_member( - self, channel_id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def is_user_channel_member(self, channel_id: str, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: membership = ( db.query(ChannelMember) @@ -729,9 +672,7 @@ def is_user_channel_member( ) return membership is not None - def get_channel_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ChannelModel]: + def get_channel_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChannelModel]: try: with get_db_context(db) as db: channel = db.query(Channel).filter(Channel.id == id).first() @@ -739,18 +680,12 @@ def get_channel_by_id( except Exception: return None - def get_channels_by_file_id( - self, file_id: str, db: Optional[Session] = None - ) -> list[ChannelModel]: + def get_channels_by_file_id(self, file_id: str, db: Optional[Session] = None) -> list[ChannelModel]: with get_db_context(db) as db: - channel_files = ( - db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() - ) + channel_files = db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() channel_ids = [cf.channel_id for cf in channel_files] channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all() - grants_map = AccessGrants.get_grants_by_resources( - "channel", channel_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('channel', channel_ids, db=db) return [ self._to_channel_model( channel, @@ -765,9 +700,7 @@ def get_channels_by_file_id_and_user_id( ) -> list[ChannelModel]: with get_db_context(db) as db: # 1. Determine which channels have this file - channel_file_rows = ( - db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() - ) + channel_file_rows = db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all() channel_ids = [row.channel_id for row in channel_file_rows] if not channel_ids: @@ -787,15 +720,13 @@ def get_channels_by_file_id_and_user_id( return [] # Preload user's group membership - user_group_ids = [ - g.id for g in Groups.get_groups_by_member_id(user_id, db=db) - ] + user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id, db=db)] allowed_channels = [] for channel in channels: # --- Case A: group or dm => user must be an active member --- - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: membership = ( db.query(ChannelMember) .filter( @@ -815,8 +746,8 @@ def get_channels_by_file_id_and_user_id( query = self._has_permission( db, query, - {"user_id": user_id, "group_ids": user_group_ids}, - permission="read", + {'user_id': user_id, 'group_ids': user_group_ids}, + permission='read', ) allowed = query.first() @@ -844,7 +775,7 @@ def get_channel_by_id_and_user_id( return None # If the channel is a group or dm, read access requires membership (active) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: membership = ( db.query(ChannelMember) .filter( @@ -863,24 +794,18 @@ def get_channel_by_id_and_user_id( query = db.query(Channel).filter(Channel.id == id) # Determine user groups - user_group_ids = [ - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - ] + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id, db=db)] # Apply ACL rules query = self._has_permission( db, query, - {"user_id": user_id, "group_ids": user_group_ids}, - permission="read", + {'user_id': user_id, 'group_ids': user_group_ids}, + permission='read', ) channel_allowed = query.first() - return ( - self._to_channel_model(channel_allowed, db=db) - if channel_allowed - else None - ) + return self._to_channel_model(channel_allowed, db=db) if channel_allowed else None def update_channel_by_id( self, id: str, form_data: ChannelForm, db: Optional[Session] = None @@ -898,9 +823,7 @@ def update_channel_by_id( channel.meta = form_data.meta if form_data.access_grants is not None: - AccessGrants.set_access_grants( - "channel", id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('channel', id, form_data.access_grants, db=db) channel.updated_at = int(time.time_ns()) db.commit() @@ -912,12 +835,12 @@ def add_file_to_channel_by_id( with get_db_context(db) as db: channel_file = ChannelFileModel( **{ - "id": str(uuid.uuid4()), - "channel_id": channel_id, - "file_id": file_id, - "user_id": user_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), + 'id': str(uuid.uuid4()), + 'channel_id': channel_id, + 'file_id': file_id, + 'user_id': user_id, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) @@ -942,11 +865,7 @@ def set_file_message_id_in_channel_by_id( ) -> bool: try: with get_db_context(db) as db: - channel_file = ( - db.query(ChannelFile) - .filter_by(channel_id=channel_id, file_id=file_id) - .first() - ) + channel_file = db.query(ChannelFile).filter_by(channel_id=channel_id, file_id=file_id).first() if not channel_file: return False @@ -958,14 +877,10 @@ def set_file_message_id_in_channel_by_id( except Exception: return False - def remove_file_from_channel_by_id( - self, channel_id: str, file_id: str, db: Optional[Session] = None - ) -> bool: + def remove_file_from_channel_by_id(self, channel_id: str, file_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - db.query(ChannelFile).filter_by( - channel_id=channel_id, file_id=file_id - ).delete() + db.query(ChannelFile).filter_by(channel_id=channel_id, file_id=file_id).delete() db.commit() return True except Exception: @@ -973,7 +888,7 @@ def remove_file_from_channel_by_id( def delete_channel_by_id(self, id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: - AccessGrants.revoke_all_access("channel", id, db=db) + AccessGrants.revoke_all_access('channel', id, db=db) db.query(Channel).filter(Channel.id == id).delete() db.commit() return True @@ -1005,24 +920,14 @@ def insert_webhook( db.commit() return webhook - def get_webhooks_by_channel_id( - self, channel_id: str, db: Optional[Session] = None - ) -> list[ChannelWebhookModel]: + def get_webhooks_by_channel_id(self, channel_id: str, db: Optional[Session] = None) -> list[ChannelWebhookModel]: with get_db_context(db) as db: - webhooks = ( - db.query(ChannelWebhook) - .filter(ChannelWebhook.channel_id == channel_id) - .all() - ) + webhooks = db.query(ChannelWebhook).filter(ChannelWebhook.channel_id == channel_id).all() return [ChannelWebhookModel.model_validate(w) for w in webhooks] - def get_webhook_by_id( - self, webhook_id: str, db: Optional[Session] = None - ) -> Optional[ChannelWebhookModel]: + def get_webhook_by_id(self, webhook_id: str, db: Optional[Session] = None) -> Optional[ChannelWebhookModel]: with get_db_context(db) as db: - webhook = ( - db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() - ) + webhook = db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() return ChannelWebhookModel.model_validate(webhook) if webhook else None def get_webhook_by_id_and_token( @@ -1046,9 +951,7 @@ def update_webhook_by_id( db: Optional[Session] = None, ) -> Optional[ChannelWebhookModel]: with get_db_context(db) as db: - webhook = ( - db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() - ) + webhook = db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() if not webhook: return None webhook.name = form_data.name @@ -1057,28 +960,18 @@ def update_webhook_by_id( db.commit() return ChannelWebhookModel.model_validate(webhook) - def update_webhook_last_used_at( - self, webhook_id: str, db: Optional[Session] = None - ) -> bool: + def update_webhook_last_used_at(self, webhook_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: - webhook = ( - db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() - ) + webhook = db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).first() if not webhook: return False webhook.last_used_at = int(time.time_ns()) db.commit() return True - def delete_webhook_by_id( - self, webhook_id: str, db: Optional[Session] = None - ) -> bool: + def delete_webhook_by_id(self, webhook_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: - result = ( - db.query(ChannelWebhook) - .filter(ChannelWebhook.id == webhook_id) - .delete() - ) + result = db.query(ChannelWebhook).filter(ChannelWebhook.id == webhook_id).delete() db.commit() return result > 0 diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index 00609ce7f0..97490c1602 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -47,13 +47,11 @@ def _normalize_timestamp(timestamp: int) -> float: class ChatMessage(Base): - __tablename__ = "chat_message" + __tablename__ = 'chat_message' # Identity id = Column(Text, primary_key=True) - chat_id = Column( - Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False, index=True - ) + chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False, index=True) user_id = Column(Text, index=True) # Structure @@ -85,9 +83,9 @@ class ChatMessage(Base): updated_at = Column(BigInteger) __table_args__ = ( - Index("chat_message_chat_parent_idx", "chat_id", "parent_id"), - Index("chat_message_model_created_idx", "model_id", "created_at"), - Index("chat_message_user_created_idx", "user_id", "created_at"), + Index('chat_message_chat_parent_idx', 'chat_id', 'parent_id'), + Index('chat_message_model_created_idx', 'model_id', 'created_at'), + Index('chat_message_user_created_idx', 'user_id', 'created_at'), ) @@ -135,43 +133,41 @@ def upsert_message( """Insert or update a chat message.""" with get_db_context(db) as db: now = int(time.time()) - timestamp = data.get("timestamp", now) + timestamp = data.get('timestamp', now) # Use composite ID: {chat_id}-{message_id} - composite_id = f"{chat_id}-{message_id}" + composite_id = f'{chat_id}-{message_id}' existing = db.get(ChatMessage, composite_id) if existing: # Update existing - if "role" in data: - existing.role = data["role"] - if "parent_id" in data: - existing.parent_id = data.get("parent_id") or data.get("parentId") - if "content" in data: - existing.content = data.get("content") - if "output" in data: - existing.output = data.get("output") - if "model_id" in data or "model" in data: - existing.model_id = data.get("model_id") or data.get("model") - if "files" in data: - existing.files = data.get("files") - if "sources" in data: - existing.sources = data.get("sources") - if "embeds" in data: - existing.embeds = data.get("embeds") - if "done" in data: - existing.done = data.get("done", True) - if "status_history" in data or "statusHistory" in data: - existing.status_history = data.get("status_history") or data.get( - "statusHistory" - ) - if "error" in data: - existing.error = data.get("error") + if 'role' in data: + existing.role = data['role'] + if 'parent_id' in data: + existing.parent_id = data.get('parent_id') or data.get('parentId') + if 'content' in data: + existing.content = data.get('content') + if 'output' in data: + existing.output = data.get('output') + if 'model_id' in data or 'model' in data: + existing.model_id = data.get('model_id') or data.get('model') + if 'files' in data: + existing.files = data.get('files') + if 'sources' in data: + existing.sources = data.get('sources') + if 'embeds' in data: + existing.embeds = data.get('embeds') + if 'done' in data: + existing.done = data.get('done', True) + if 'status_history' in data or 'statusHistory' in data: + existing.status_history = data.get('status_history') or data.get('statusHistory') + if 'error' in data: + existing.error = data.get('error') # Extract usage - check direct field first, then info.usage - usage = data.get("usage") + usage = data.get('usage') if not usage: - info = data.get("info", {}) - usage = info.get("usage") if info else None + info = data.get('info', {}) + usage = info.get('usage') if info else None if usage: existing.usage = usage existing.updated_at = now @@ -181,26 +177,25 @@ def upsert_message( else: # Insert new # Extract usage - check direct field first, then info.usage - usage = data.get("usage") + usage = data.get('usage') if not usage: - info = data.get("info", {}) - usage = info.get("usage") if info else None + info = data.get('info', {}) + usage = info.get('usage') if info else None message = ChatMessage( id=composite_id, chat_id=chat_id, user_id=user_id, - role=data.get("role", "user"), - parent_id=data.get("parent_id") or data.get("parentId"), - content=data.get("content"), - output=data.get("output"), - model_id=data.get("model_id") or data.get("model"), - files=data.get("files"), - sources=data.get("sources"), - embeds=data.get("embeds"), - done=data.get("done", True), - status_history=data.get("status_history") - or data.get("statusHistory"), - error=data.get("error"), + role=data.get('role', 'user'), + parent_id=data.get('parent_id') or data.get('parentId'), + content=data.get('content'), + output=data.get('output'), + model_id=data.get('model_id') or data.get('model'), + files=data.get('files'), + sources=data.get('sources'), + embeds=data.get('embeds'), + done=data.get('done', True), + status_history=data.get('status_history') or data.get('statusHistory'), + error=data.get('error'), usage=usage, created_at=timestamp, updated_at=now, @@ -210,23 +205,14 @@ def upsert_message( db.refresh(message) return ChatMessageModel.model_validate(message) - def get_message_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ChatMessageModel]: + def get_message_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatMessageModel]: with get_db_context(db) as db: message = db.get(ChatMessage, id) return ChatMessageModel.model_validate(message) if message else None - def get_messages_by_chat_id( - self, chat_id: str, db: Optional[Session] = None - ) -> list[ChatMessageModel]: + def get_messages_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> list[ChatMessageModel]: with get_db_context(db) as db: - messages = ( - db.query(ChatMessage) - .filter_by(chat_id=chat_id) - .order_by(ChatMessage.created_at.asc()) - .all() - ) + messages = db.query(ChatMessage).filter_by(chat_id=chat_id).order_by(ChatMessage.created_at.asc()).all() return [ChatMessageModel.model_validate(message) for message in messages] def get_messages_by_user_id( @@ -262,12 +248,7 @@ def get_messages_by_model_id( query = query.filter(ChatMessage.created_at >= start_date) if end_date: query = query.filter(ChatMessage.created_at <= end_date) - messages = ( - query.order_by(ChatMessage.created_at.desc()) - .offset(skip) - .limit(limit) - .all() - ) + messages = query.order_by(ChatMessage.created_at.desc()).offset(skip).limit(limit).all() return [ChatMessageModel.model_validate(message) for message in messages] def get_chat_ids_by_model_id( @@ -284,7 +265,7 @@ def get_chat_ids_by_model_id( with get_db_context(db) as db: query = db.query( ChatMessage.chat_id, - func.max(ChatMessage.created_at).label("last_message_at"), + func.max(ChatMessage.created_at).label('last_message_at'), ).filter(ChatMessage.model_id == model_id) if start_date: query = query.filter(ChatMessage.created_at >= start_date) @@ -303,9 +284,7 @@ def get_chat_ids_by_model_id( ) return [chat_id for chat_id, _ in chat_ids] - def delete_messages_by_chat_id( - self, chat_id: str, db: Optional[Session] = None - ) -> bool: + def delete_messages_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: db.query(ChatMessage).filter_by(chat_id=chat_id).delete() db.commit() @@ -323,12 +302,10 @@ def get_message_count_by_model( from sqlalchemy import func from open_webui.models.groups import GroupMember - query = db.query( - ChatMessage.model_id, func.count(ChatMessage.id).label("count") - ).filter( - ChatMessage.role == "assistant", + query = db.query(ChatMessage.model_id, func.count(ChatMessage.id).label('count')).filter( + ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), - ~ChatMessage.user_id.like("shared-%"), + ~ChatMessage.user_id.like('shared-%'), ) if start_date: @@ -336,11 +313,7 @@ def get_message_count_by_model( if end_date: query = query.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = ( - db.query(GroupMember.user_id) - .filter(GroupMember.group_id == group_id) - .subquery() - ) + group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() query = query.filter(ChatMessage.user_id.in_(group_users)) results = query.group_by(ChatMessage.model_id).all() @@ -360,36 +333,32 @@ def get_token_usage_by_model( dialect = db.bind.dialect.name - if dialect == "sqlite": - input_tokens = cast( - func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer - ) - output_tokens = cast( - func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer - ) - elif dialect == "postgresql": + if dialect == 'sqlite': + input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer) + output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer) + elif dialect == 'postgresql': # Use json_extract_path_text for PostgreSQL JSON columns input_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, "input_tokens"), + func.json_extract_path_text(ChatMessage.usage, 'input_tokens'), Integer, ) output_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, "output_tokens"), + func.json_extract_path_text(ChatMessage.usage, 'output_tokens'), Integer, ) else: - raise NotImplementedError(f"Unsupported dialect: {dialect}") + raise NotImplementedError(f'Unsupported dialect: {dialect}') query = db.query( ChatMessage.model_id, - func.coalesce(func.sum(input_tokens), 0).label("input_tokens"), - func.coalesce(func.sum(output_tokens), 0).label("output_tokens"), - func.count(ChatMessage.id).label("message_count"), + func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), + func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), + func.count(ChatMessage.id).label('message_count'), ).filter( - ChatMessage.role == "assistant", + ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), ChatMessage.usage.isnot(None), - ~ChatMessage.user_id.like("shared-%"), + ~ChatMessage.user_id.like('shared-%'), ) if start_date: @@ -397,21 +366,17 @@ def get_token_usage_by_model( if end_date: query = query.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = ( - db.query(GroupMember.user_id) - .filter(GroupMember.group_id == group_id) - .subquery() - ) + group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() query = query.filter(ChatMessage.user_id.in_(group_users)) results = query.group_by(ChatMessage.model_id).all() return { row.model_id: { - "input_tokens": row.input_tokens, - "output_tokens": row.output_tokens, - "total_tokens": row.input_tokens + row.output_tokens, - "message_count": row.message_count, + 'input_tokens': row.input_tokens, + 'output_tokens': row.output_tokens, + 'total_tokens': row.input_tokens + row.output_tokens, + 'message_count': row.message_count, } for row in results } @@ -430,36 +395,32 @@ def get_token_usage_by_user( dialect = db.bind.dialect.name - if dialect == "sqlite": - input_tokens = cast( - func.json_extract(ChatMessage.usage, "$.input_tokens"), Integer - ) - output_tokens = cast( - func.json_extract(ChatMessage.usage, "$.output_tokens"), Integer - ) - elif dialect == "postgresql": + if dialect == 'sqlite': + input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer) + output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer) + elif dialect == 'postgresql': # Use json_extract_path_text for PostgreSQL JSON columns input_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, "input_tokens"), + func.json_extract_path_text(ChatMessage.usage, 'input_tokens'), Integer, ) output_tokens = cast( - func.json_extract_path_text(ChatMessage.usage, "output_tokens"), + func.json_extract_path_text(ChatMessage.usage, 'output_tokens'), Integer, ) else: - raise NotImplementedError(f"Unsupported dialect: {dialect}") + raise NotImplementedError(f'Unsupported dialect: {dialect}') query = db.query( ChatMessage.user_id, - func.coalesce(func.sum(input_tokens), 0).label("input_tokens"), - func.coalesce(func.sum(output_tokens), 0).label("output_tokens"), - func.count(ChatMessage.id).label("message_count"), + func.coalesce(func.sum(input_tokens), 0).label('input_tokens'), + func.coalesce(func.sum(output_tokens), 0).label('output_tokens'), + func.count(ChatMessage.id).label('message_count'), ).filter( - ChatMessage.role == "assistant", + ChatMessage.role == 'assistant', ChatMessage.user_id.isnot(None), ChatMessage.usage.isnot(None), - ~ChatMessage.user_id.like("shared-%"), + ~ChatMessage.user_id.like('shared-%'), ) if start_date: @@ -467,21 +428,17 @@ def get_token_usage_by_user( if end_date: query = query.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = ( - db.query(GroupMember.user_id) - .filter(GroupMember.group_id == group_id) - .subquery() - ) + group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() query = query.filter(ChatMessage.user_id.in_(group_users)) results = query.group_by(ChatMessage.user_id).all() return { row.user_id: { - "input_tokens": row.input_tokens, - "output_tokens": row.output_tokens, - "total_tokens": row.input_tokens + row.output_tokens, - "message_count": row.message_count, + 'input_tokens': row.input_tokens, + 'output_tokens': row.output_tokens, + 'total_tokens': row.input_tokens + row.output_tokens, + 'message_count': row.message_count, } for row in results } @@ -497,20 +454,16 @@ def get_message_count_by_user( from sqlalchemy import func from open_webui.models.groups import GroupMember - query = db.query( - ChatMessage.user_id, func.count(ChatMessage.id).label("count") - ).filter(~ChatMessage.user_id.like("shared-%")) + query = db.query(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter( + ~ChatMessage.user_id.like('shared-%') + ) if start_date: query = query.filter(ChatMessage.created_at >= start_date) if end_date: query = query.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = ( - db.query(GroupMember.user_id) - .filter(GroupMember.group_id == group_id) - .subquery() - ) + group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() query = query.filter(ChatMessage.user_id.in_(group_users)) results = query.group_by(ChatMessage.user_id).all() @@ -527,20 +480,16 @@ def get_message_count_by_chat( from sqlalchemy import func from open_webui.models.groups import GroupMember - query = db.query( - ChatMessage.chat_id, func.count(ChatMessage.id).label("count") - ).filter(~ChatMessage.user_id.like("shared-%")) + query = db.query(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter( + ~ChatMessage.user_id.like('shared-%') + ) if start_date: query = query.filter(ChatMessage.created_at >= start_date) if end_date: query = query.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = ( - db.query(GroupMember.user_id) - .filter(GroupMember.group_id == group_id) - .subquery() - ) + group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() query = query.filter(ChatMessage.user_id.in_(group_users)) results = query.group_by(ChatMessage.chat_id).all() @@ -559,9 +508,9 @@ def get_daily_message_counts_by_model( from open_webui.models.groups import GroupMember query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( - ChatMessage.role == "assistant", + ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), - ~ChatMessage.user_id.like("shared-%"), + ~ChatMessage.user_id.like('shared-%'), ) if start_date: @@ -569,11 +518,7 @@ def get_daily_message_counts_by_model( if end_date: query = query.filter(ChatMessage.created_at <= end_date) if group_id: - group_users = ( - db.query(GroupMember.user_id) - .filter(GroupMember.group_id == group_id) - .subquery() - ) + group_users = db.query(GroupMember.user_id).filter(GroupMember.group_id == group_id).subquery() query = query.filter(ChatMessage.user_id.in_(group_users)) results = query.all() @@ -581,21 +526,17 @@ def get_daily_message_counts_by_model( # Group by date -> model -> count daily_counts: dict[str, dict[str, int]] = {} for timestamp, model_id in results: - date_str = datetime.fromtimestamp( - _normalize_timestamp(timestamp) - ).strftime("%Y-%m-%d") + date_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d') if date_str not in daily_counts: daily_counts[date_str] = {} - daily_counts[date_str][model_id] = ( - daily_counts[date_str].get(model_id, 0) + 1 - ) + daily_counts[date_str][model_id] = daily_counts[date_str].get(model_id, 0) + 1 # Fill in missing days if start_date and end_date: current = datetime.fromtimestamp(_normalize_timestamp(start_date)) end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) while current <= end_dt: - date_str = current.strftime("%Y-%m-%d") + date_str = current.strftime('%Y-%m-%d') if date_str not in daily_counts: daily_counts[date_str] = {} current += timedelta(days=1) @@ -613,9 +554,9 @@ def get_hourly_message_counts_by_model( from datetime import datetime, timedelta query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( - ChatMessage.role == "assistant", + ChatMessage.role == 'assistant', ChatMessage.model_id.isnot(None), - ~ChatMessage.user_id.like("shared-%"), + ~ChatMessage.user_id.like('shared-%'), ) if start_date: @@ -628,23 +569,19 @@ def get_hourly_message_counts_by_model( # Group by hour -> model -> count hourly_counts: dict[str, dict[str, int]] = {} for timestamp, model_id in results: - hour_str = datetime.fromtimestamp( - _normalize_timestamp(timestamp) - ).strftime("%Y-%m-%d %H:00") + hour_str = datetime.fromtimestamp(_normalize_timestamp(timestamp)).strftime('%Y-%m-%d %H:00') if hour_str not in hourly_counts: hourly_counts[hour_str] = {} - hourly_counts[hour_str][model_id] = ( - hourly_counts[hour_str].get(model_id, 0) + 1 - ) + hourly_counts[hour_str][model_id] = hourly_counts[hour_str].get(model_id, 0) + 1 # Fill in missing hours if start_date and end_date: - current = datetime.fromtimestamp( - _normalize_timestamp(start_date) - ).replace(minute=0, second=0, microsecond=0) + current = datetime.fromtimestamp(_normalize_timestamp(start_date)).replace( + minute=0, second=0, microsecond=0 + ) end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date)) while current <= end_dt: - hour_str = current.strftime("%Y-%m-%d %H:00") + hour_str = current.strftime('%Y-%m-%d %H:00') if hour_str not in hourly_counts: hourly_counts[hour_str] = {} current += timedelta(hours=1) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 78aa4d3ded..f19a5e7537 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -29,13 +29,15 @@ #################### # Chat DB Schema +# Let no word spoken in this house be lost, and when the +# record is read again, let it still serve the one who spoke. #################### log = logging.getLogger(__name__) class Chat(Base): - __tablename__ = "chat" + __tablename__ = 'chat' id = Column(String, primary_key=True, unique=True) user_id = Column(String) @@ -49,21 +51,21 @@ class Chat(Base): archived = Column(Boolean, default=False) pinned = Column(Boolean, default=False, nullable=True) - meta = Column(JSON, server_default="{}") + meta = Column(JSON, server_default='{}') folder_id = Column(Text, nullable=True) __table_args__ = ( # Performance indexes for common queries # WHERE folder_id = ... - Index("folder_id_idx", "folder_id"), + Index('folder_id_idx', 'folder_id'), # WHERE user_id = ... AND pinned = ... - Index("user_id_pinned_idx", "user_id", "pinned"), + Index('user_id_pinned_idx', 'user_id', 'pinned'), # WHERE user_id = ... AND archived = ... - Index("user_id_archived_idx", "user_id", "archived"), + Index('user_id_archived_idx', 'user_id', 'archived'), # WHERE user_id = ... ORDER BY updated_at DESC - Index("updated_at_user_id_idx", "updated_at", "user_id"), + Index('updated_at_user_id_idx', 'updated_at', 'user_id'), # WHERE folder_id = ... AND user_id = ... - Index("folder_id_user_id_idx", "folder_id", "user_id"), + Index('folder_id_user_id_idx', 'folder_id', 'user_id'), ) @@ -87,21 +89,19 @@ class ChatModel(BaseModel): class ChatFile(Base): - __tablename__ = "chat_file" + __tablename__ = 'chat_file' id = Column(Text, unique=True, primary_key=True) user_id = Column(Text, nullable=False) - chat_id = Column(Text, ForeignKey("chat.id", ondelete="CASCADE"), nullable=False) + chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False) message_id = Column(Text, nullable=True) - file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False) + file_id = Column(Text, ForeignKey('file.id', ondelete='CASCADE'), nullable=False) created_at = Column(BigInteger, nullable=False) updated_at = Column(BigInteger, nullable=False) - __table_args__ = ( - UniqueConstraint("chat_id", "file_id", name="uq_chat_file_chat_file"), - ) + __table_args__ = (UniqueConstraint('chat_id', 'file_id', name='uq_chat_file_chat_file'),) class ChatFileModel(BaseModel): @@ -191,19 +191,11 @@ class ChatUsageStatsResponse(BaseModel): history_models: dict = {} # models used in the chat history with their usage counts history_message_count: int # number of messages in the chat history history_user_message_count: int # number of user messages in the chat history - history_assistant_message_count: ( - int # number of assistant messages in the chat history - ) + history_assistant_message_count: int # number of assistant messages in the chat history - average_response_time: ( - float # average response time of assistant messages in seconds - ) - average_user_message_content_length: ( - float # average length of user message contents - ) - average_assistant_message_content_length: ( - float # average length of assistant message contents - ) + average_response_time: float # average response time of assistant messages in seconds + average_user_message_content_length: float # average length of user message contents + average_assistant_message_content_length: float # average length of assistant message contents tags: list[str] = [] # tags associated with the chat @@ -211,13 +203,13 @@ class ChatUsageStatsResponse(BaseModel): updated_at: int created_at: int - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class ChatUsageStatsListResponse(BaseModel): items: list[ChatUsageStatsResponse] total: int - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class MessageStats(BaseModel): @@ -290,24 +282,20 @@ def _sanitize_chat_row(self, chat_item): return changed - def insert_new_chat( - self, user_id: str, form_data: ChatForm, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def insert_new_chat(self, user_id: str, form_data: ChatForm, db: Optional[Session] = None) -> Optional[ChatModel]: with get_db_context(db) as db: id = str(uuid.uuid4()) chat = ChatModel( **{ - "id": id, - "user_id": user_id, - "title": self._clean_null_bytes( - form_data.chat["title"] - if "title" in form_data.chat - else "New Chat" + 'id': id, + 'user_id': user_id, + '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), - "folder_id": form_data.folder_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), + 'chat': self._clean_null_bytes(form_data.chat), + 'folder_id': form_data.folder_id, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) @@ -318,10 +306,10 @@ def insert_new_chat( # 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', {}) + messages = history.get('messages', {}) for message_id, message in messages.items(): - if isinstance(message, dict) and message.get("role"): + if isinstance(message, dict) and message.get('role'): ChatMessages.upsert_message( message_id=message_id, chat_id=id, @@ -329,33 +317,23 @@ def insert_new_chat( data=message, ) except Exception as e: - log.warning( - f"Failed to write initial messages to chat_message table: {e}" - ) + log.warning(f'Failed to write initial messages to chat_message table: {e}') return ChatModel.model_validate(chat_item) if chat_item else None - def _chat_import_form_to_chat_model( - self, user_id: str, form_data: ChatImportForm - ) -> ChatModel: + def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel: id = str(uuid.uuid4()) chat = ChatModel( **{ - "id": id, - "user_id": user_id, - "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, - "pinned": form_data.pinned, - "folder_id": form_data.folder_id, - "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()) - ), + 'id': id, + 'user_id': user_id, + '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, + 'pinned': form_data.pinned, + 'folder_id': form_data.folder_id, + '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())), } ) return chat @@ -379,10 +357,10 @@ def import_chats( # Dual-write messages to chat_message table try: 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', {}) + messages = history.get('messages', {}) for message_id, message in messages.items(): - if isinstance(message, dict) and message.get("role"): + if isinstance(message, dict) and message.get('role'): ChatMessages.upsert_message( message_id=message_id, chat_id=chat_obj.id, @@ -390,24 +368,16 @@ def import_chats( data=message, ) except Exception as e: - log.warning( - f"Failed to write imported messages to chat_message table: {e}" - ) + log.warning(f'Failed to write imported messages to chat_message table: {e}') return [ChatModel.model_validate(chat) for chat in chats] - def update_chat_by_id( - self, id: str, chat: dict, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def update_chat_by_id(self, id: str, chat: dict, db: Optional[Session] = None) -> Optional[ChatModel]: try: with get_db_context(db) as db: chat_item = db.get(Chat, id) chat_item.chat = self._clean_null_bytes(chat) - chat_item.title = ( - self._clean_null_bytes(chat["title"]) - if "title" in chat - else "New Chat" - ) + chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat' chat_item.updated_at = int(time.time()) @@ -424,24 +394,22 @@ def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]: return None chat = chat.chat - chat["title"] = title + chat['title'] = title return self.update_chat_by_id(id, chat) - def update_chat_tags_by_id( - self, id: str, tags: list[str], user - ) -> Optional[ChatModel]: + def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> Optional[ChatModel]: with get_db_context() as db: chat = db.get(Chat, id) if chat is None: return None - old_tags = chat.meta.get("tags", []) - new_tags = [t for t in tags if t.replace(" ", "_").lower() != "none"] - new_tag_ids = [t.replace(" ", "_").lower() for t in new_tags] + old_tags = chat.meta.get('tags', []) + new_tags = [t for t in tags if t.replace(' ', '_').lower() != 'none'] + new_tag_ids = [t.replace(' ', '_').lower() for t in new_tags] # Single meta update - chat.meta = {**chat.meta, "tags": new_tag_ids} + chat.meta = {**chat.meta, 'tags': new_tag_ids} db.commit() db.refresh(chat) @@ -460,23 +428,21 @@ def get_chat_title_by_id(self, id: str) -> Optional[str]: result = db.query(Chat.title).filter_by(id=id).first() if result is None: return None - return result[0] or "New Chat" + return result[0] or 'New Chat' def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]: chat = self.get_chat_by_id(id) if chat is None: return None - return chat.chat.get("history", {}).get("messages", {}) or {} + return chat.chat.get('history', {}).get('messages', {}) or {} - def get_message_by_id_and_message_id( - self, id: str, message_id: str - ) -> Optional[dict]: + def get_message_by_id_and_message_id(self, id: str, message_id: str) -> Optional[dict]: chat = self.get_chat_by_id(id) if chat is None: return None - return chat.chat.get("history", {}).get("messages", {}).get(message_id, {}) + return chat.chat.get('history', {}).get('messages', {}).get(message_id, {}) def upsert_message_to_chat_by_id_and_message_id( self, id: str, message_id: str, message: dict @@ -486,24 +452,24 @@ def upsert_message_to_chat_by_id_and_message_id( return None # Sanitize message content for null characters before upserting - if isinstance(message.get("content"), str): - message["content"] = sanitize_text_for_db(message["content"]) + if isinstance(message.get('content'), str): + message['content'] = sanitize_text_for_db(message['content']) user_id = chat.user_id chat = chat.chat - history = chat.get("history", {}) + history = chat.get('history', {}) - if message_id in history.get("messages", {}): - history["messages"][message_id] = { - **history["messages"][message_id], + if message_id in history.get('messages', {}): + history['messages'][message_id] = { + **history['messages'][message_id], **message, } else: - history["messages"][message_id] = message + history['messages'][message_id] = message - history["currentId"] = message_id + history['currentId'] = message_id - chat["history"] = history + chat['history'] = history # Dual-write to chat_message table try: @@ -511,10 +477,10 @@ def upsert_message_to_chat_by_id_and_message_id( message_id=message_id, chat_id=id, user_id=user_id, - data=history["messages"][message_id], + data=history['messages'][message_id], ) except Exception as e: - log.warning(f"Failed to write to chat_message table: {e}") + log.warning(f'Failed to write to chat_message table: {e}') return self.update_chat_by_id(id, chat) @@ -526,41 +492,37 @@ def add_message_status_to_chat_by_id_and_message_id( return None chat = chat.chat - history = chat.get("history", {}) + history = chat.get('history', {}) - if message_id in history.get("messages", {}): - status_history = history["messages"][message_id].get("statusHistory", []) + if message_id in history.get('messages', {}): + status_history = history['messages'][message_id].get('statusHistory', []) status_history.append(status) - history["messages"][message_id]["statusHistory"] = status_history + history['messages'][message_id]['statusHistory'] = status_history - chat["history"] = history + chat['history'] = history return self.update_chat_by_id(id, chat) - def add_message_files_by_id_and_message_id( - self, id: str, message_id: str, files: list[dict] - ) -> list[dict]: + def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]: with get_db_context() as db: chat = self.get_chat_by_id(id, db=db) if chat is None: return None chat = chat.chat - history = chat.get("history", {}) + history = chat.get('history', {}) message_files = [] - if message_id in history.get("messages", {}): - message_files = history["messages"][message_id].get("files", []) + if message_id in history.get('messages', {}): + message_files = history['messages'][message_id].get('files', []) message_files = message_files + files - history["messages"][message_id]["files"] = message_files + history['messages'][message_id]['files'] = message_files - chat["history"] = history + chat['history'] = history self.update_chat_by_id(id, chat, db=db) return message_files - def insert_shared_chat_by_chat_id( - self, chat_id: str, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def insert_shared_chat_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> Optional[ChatModel]: with get_db_context(db) as db: # Get the existing chat to share chat = db.get(Chat, chat_id) @@ -569,19 +531,19 @@ def insert_shared_chat_by_chat_id( return None # Check if the chat is already shared if chat.share_id: - return self.get_chat_by_id_and_user_id(chat.share_id, "shared", db=db) + return self.get_chat_by_id_and_user_id(chat.share_id, 'shared', db=db) # Create a new chat with the same data, but with a new ID shared_chat = ChatModel( **{ - "id": str(uuid.uuid4()), - "user_id": f"shared-{chat_id}", - "title": chat.title, - "chat": chat.chat, - "meta": chat.meta, - "pinned": chat.pinned, - "folder_id": chat.folder_id, - "created_at": chat.created_at, - "updated_at": int(time.time()), + 'id': str(uuid.uuid4()), + 'user_id': f'shared-{chat_id}', + 'title': chat.title, + 'chat': chat.chat, + 'meta': chat.meta, + 'pinned': chat.pinned, + 'folder_id': chat.folder_id, + 'created_at': chat.created_at, + 'updated_at': int(time.time()), } ) shared_result = Chat(**shared_chat.model_dump()) @@ -590,23 +552,15 @@ def insert_shared_chat_by_chat_id( db.refresh(shared_result) # Update the original chat with the share_id - result = ( - db.query(Chat) - .filter_by(id=chat_id) - .update({"share_id": shared_chat.id}) - ) + result = db.query(Chat).filter_by(id=chat_id).update({'share_id': shared_chat.id}) db.commit() return shared_chat if (shared_result and result) else None - def update_shared_chat_by_chat_id( - self, chat_id: str, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def update_shared_chat_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> Optional[ChatModel]: try: with get_db_context(db) as db: chat = db.get(Chat, chat_id) - shared_chat = ( - db.query(Chat).filter_by(user_id=f"shared-{chat_id}").first() - ) + shared_chat = db.query(Chat).filter_by(user_id=f'shared-{chat_id}').first() if shared_chat is None: return self.insert_shared_chat_by_chat_id(chat_id, db=db) @@ -624,33 +578,25 @@ def update_shared_chat_by_chat_id( except Exception: return None - def delete_shared_chat_by_chat_id( - self, chat_id: str, db: Optional[Session] = None - ) -> bool: + def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: # Use subquery to delete chat_messages for shared chats - shared_chat_id_subquery = ( - db.query(Chat.id) - .filter_by(user_id=f"shared-{chat_id}") - .scalar_subquery() + shared_chat_id_subquery = db.query(Chat.id).filter_by(user_id=f'shared-{chat_id}').scalar_subquery() + db.query(ChatMessage).filter(ChatMessage.chat_id.in_(shared_chat_id_subquery)).delete( + synchronize_session=False ) - db.query(ChatMessage).filter( - ChatMessage.chat_id.in_(shared_chat_id_subquery) - ).delete(synchronize_session=False) - db.query(Chat).filter_by(user_id=f"shared-{chat_id}").delete() + db.query(Chat).filter_by(user_id=f'shared-{chat_id}').delete() db.commit() return True except Exception: return False - def unarchive_all_chats_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> bool: + def unarchive_all_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - db.query(Chat).filter_by(user_id=user_id).update({"archived": False}) + db.query(Chat).filter_by(user_id=user_id).update({'archived': False}) db.commit() return True except Exception: @@ -669,9 +615,7 @@ def update_chat_share_id_by_id( except Exception: return None - def toggle_chat_pinned_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def toggle_chat_pinned_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatModel]: try: with get_db_context(db) as db: chat = db.get(Chat, id) @@ -683,9 +627,7 @@ def toggle_chat_pinned_by_id( except Exception: return None - def toggle_chat_archive_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def toggle_chat_archive_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatModel]: try: with get_db_context(db) as db: chat = db.get(Chat, id) @@ -698,12 +640,10 @@ def toggle_chat_archive_by_id( except Exception: return None - def archive_all_chats_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> bool: + def archive_all_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - db.query(Chat).filter_by(user_id=user_id).update({"archived": True}) + db.query(Chat).filter_by(user_id=user_id).update({'archived': True}) db.commit() return True except Exception: @@ -717,34 +657,31 @@ def get_archived_chat_list_by_user_id( limit: int = 50, db: Optional[Session] = None, ) -> list[ChatTitleIdResponse]: - with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id, archived=True) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: - query = query.filter(Chat.title.ilike(f"%{query_key}%")) + query = query.filter(Chat.title.ilike(f'%{query_key}%')) - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') if order_by and direction: if not getattr(Chat, order_by, None): - raise ValueError("Invalid order_by field") + raise ValueError('Invalid order_by field') - if direction.lower() == "asc": + if direction.lower() == 'asc': query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) - elif direction.lower() == "desc": + elif direction.lower() == 'desc': query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) else: - raise ValueError("Invalid direction for ordering") + raise ValueError('Invalid direction for ordering') else: query = query.order_by(Chat.updated_at.desc(), Chat.id) - query = query.with_entities( - Chat.id, Chat.title, Chat.updated_at, Chat.created_at - ) + query = query.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at) if skip: query = query.offset(skip) @@ -755,10 +692,10 @@ def get_archived_chat_list_by_user_id( return [ ChatTitleIdResponse.model_validate( { - "id": chat[0], - "title": chat[1], - "updated_at": chat[2], - "created_at": chat[3], + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], } ) for chat in all_chats @@ -772,32 +709,27 @@ def get_shared_chat_list_by_user_id( limit: int = 50, db: Optional[Session] = None, ) -> list[SharedChatResponse]: - with get_db_context(db) as db: - query = ( - db.query(Chat) - .filter_by(user_id=user_id) - .filter(Chat.share_id.isnot(None)) - ) + query = db.query(Chat).filter_by(user_id=user_id).filter(Chat.share_id.isnot(None)) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: - query = query.filter(Chat.title.ilike(f"%{query_key}%")) + query = query.filter(Chat.title.ilike(f'%{query_key}%')) - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') if order_by and direction: if not getattr(Chat, order_by, None): - raise ValueError("Invalid order_by field") + raise ValueError('Invalid order_by field') - if direction.lower() == "asc": + if direction.lower() == 'asc': query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) - elif direction.lower() == "desc": + elif direction.lower() == 'desc': query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) else: - raise ValueError("Invalid direction for ordering") + raise ValueError('Invalid direction for ordering') else: query = query.order_by(Chat.updated_at.desc(), Chat.id) @@ -820,11 +752,11 @@ def get_shared_chat_list_by_user_id( return [ SharedChatResponse.model_validate( { - "id": chat[0], - "title": chat[1], - "share_id": chat[2], - "updated_at": chat[3], - "created_at": chat[4], + 'id': chat[0], + 'title': chat[1], + 'share_id': chat[2], + 'updated_at': chat[3], + 'created_at': chat[4], } ) for chat in all_chats @@ -845,20 +777,20 @@ def get_chat_list_by_user_id( query = query.filter_by(archived=False) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: - query = query.filter(Chat.title.ilike(f"%{query_key}%")) + query = query.filter(Chat.title.ilike(f'%{query_key}%')) - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') if order_by and direction and getattr(Chat, order_by): - if direction.lower() == "asc": + if direction.lower() == 'asc': query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) - elif direction.lower() == "desc": + elif direction.lower() == 'desc': query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) else: - raise ValueError("Invalid direction for ordering") + raise ValueError('Invalid direction for ordering') else: query = query.order_by(Chat.updated_at.desc(), Chat.id) @@ -907,10 +839,10 @@ def get_chat_title_id_list_by_user_id( return [ ChatTitleIdResponse.model_validate( { - "id": chat[0], - "title": chat[1], - "updated_at": chat[2], - "created_at": chat[3], + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], } ) for chat in all_chats @@ -933,9 +865,7 @@ def get_chat_list_by_chat_ids( ) return [ChatModel.model_validate(chat) for chat in all_chats] - def get_chat_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def get_chat_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatModel]: try: with get_db_context(db) as db: chat_item = db.get(Chat, id) @@ -950,9 +880,7 @@ def get_chat_by_id( except Exception: return None - def get_chat_by_share_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def get_chat_by_share_id(self, id: str, db: Optional[Session] = None) -> Optional[ChatModel]: try: with get_db_context(db) as db: # it is possible that the shared link was deleted. hence, @@ -966,9 +894,7 @@ def get_chat_by_share_id( except Exception: return None - def get_chat_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> Optional[ChatModel]: + def get_chat_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[ChatModel]: try: with get_db_context(db) as db: chat = db.query(Chat).filter_by(id=id, user_id=user_id).first() @@ -976,40 +902,30 @@ def get_chat_by_id_and_user_id( except Exception: return None - def is_chat_owner( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def is_chat_owner(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: """ Lightweight ownership check โ€” uses EXISTS subquery instead of loading the full Chat row (which includes the potentially large JSON blob). """ try: with get_db_context(db) as db: - return db.query( - exists().where(and_(Chat.id == id, Chat.user_id == user_id)) - ).scalar() + return db.query(exists().where(and_(Chat.id == id, Chat.user_id == user_id))).scalar() except Exception: return False - def get_chat_folder_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> Optional[str]: + def get_chat_folder_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[str]: """ Fetch only the folder_id column for a chat, without loading the full JSON blob. Returns None if chat doesn't exist or doesn't belong to user. """ try: with get_db_context(db) as db: - result = ( - db.query(Chat.folder_id).filter_by(id=id, user_id=user_id).first() - ) + result = db.query(Chat.folder_id).filter_by(id=id, user_id=user_id).first() return result[0] if result else None except Exception: return None - def get_chats( - self, skip: int = 0, limit: int = 50, db: Optional[Session] = None - ) -> list[ChatModel]: + def get_chats(self, skip: int = 0, limit: int = 50, db: Optional[Session] = None) -> list[ChatModel]: with get_db_context(db) as db: all_chats = ( db.query(Chat) @@ -1030,22 +946,18 @@ def get_chats_by_user_id( query = db.query(Chat).filter_by(user_id=user_id) if filter: - if filter.get("updated_at"): - query = query.filter(Chat.updated_at > filter.get("updated_at")) + if filter.get('updated_at'): + query = query.filter(Chat.updated_at > filter.get('updated_at')) - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') if order_by and direction: if hasattr(Chat, order_by): - if direction.lower() == "asc": - query = query.order_by( - getattr(Chat, order_by).asc(), Chat.id - ) - elif direction.lower() == "desc": - query = query.order_by( - getattr(Chat, order_by).desc(), Chat.id - ) + if direction.lower() == 'asc': + query = query.order_by(getattr(Chat, order_by).asc(), Chat.id) + elif direction.lower() == 'desc': + query = query.order_by(getattr(Chat, order_by).desc(), Chat.id) else: query = query.order_by(Chat.updated_at.desc(), Chat.id) @@ -1063,14 +975,12 @@ def get_chats_by_user_id( return ChatListResponse( **{ - "items": [ChatModel.model_validate(chat) for chat in all_chats], - "total": total, + 'items': [ChatModel.model_validate(chat) for chat in all_chats], + 'total': total, } ) - def get_pinned_chats_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[ChatTitleIdResponse]: + def get_pinned_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[ChatTitleIdResponse]: with get_db_context(db) as db: all_chats = ( db.query(Chat) @@ -1081,24 +991,18 @@ def get_pinned_chats_by_user_id( return [ ChatTitleIdResponse.model_validate( { - "id": chat[0], - "title": chat[1], - "updated_at": chat[2], - "created_at": chat[3], + 'id': chat[0], + 'title': chat[1], + 'updated_at': chat[2], + 'created_at': chat[3], } ) for chat in all_chats ] - def get_archived_chats_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[ChatModel]: + def get_archived_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[ChatModel]: with get_db_context(db) as db: - all_chats = ( - db.query(Chat) - .filter_by(user_id=user_id, archived=True) - .order_by(Chat.updated_at.desc()) - ) + all_chats = db.query(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc()) return [ChatModel.model_validate(chat) for chat in all_chats] def get_chats_by_user_id_and_search_text( @@ -1116,61 +1020,53 @@ def get_chats_by_user_id_and_search_text( search_text = sanitize_text_for_db(search_text).lower().strip() if not search_text: - return self.get_chat_list_by_user_id( - user_id, include_archived, filter={}, skip=skip, limit=limit, db=db - ) + return self.get_chat_list_by_user_id(user_id, include_archived, filter={}, skip=skip, limit=limit, db=db) - search_text_words = search_text.split(" ") + search_text_words = search_text.split(' ') # search_text might contain 'tag:tag_name' format so we need to extract the tag_name, split the search_text and remove the tags tag_ids = [ - word.replace("tag:", "").replace(" ", "_").lower() - for word in search_text_words - if word.startswith("tag:") + word.replace('tag:', '').replace(' ', '_').lower() for word in search_text_words if word.startswith('tag:') ] # Extract folder names - handle spaces and case insensitivity folders = Folders.search_folders_by_names( user_id, - [ - word.replace("folder:", "") - for word in search_text_words - if word.startswith("folder:") - ], + [word.replace('folder:', '') for word in search_text_words if word.startswith('folder:')], ) folder_ids = [folder.id for folder in folders] is_pinned = None - if "pinned:true" in search_text_words: + if 'pinned:true' in search_text_words: is_pinned = True - elif "pinned:false" in search_text_words: + elif 'pinned:false' in search_text_words: is_pinned = False is_archived = None - if "archived:true" in search_text_words: + if 'archived:true' in search_text_words: is_archived = True - elif "archived:false" in search_text_words: + elif 'archived:false' in search_text_words: is_archived = False is_shared = None - if "shared:true" in search_text_words: + if 'shared:true' in search_text_words: is_shared = True - elif "shared:false" in search_text_words: + elif 'shared:false' in search_text_words: is_shared = False search_text_words = [ word for word in search_text_words if ( - not word.startswith("tag:") - and not word.startswith("folder:") - and not word.startswith("pinned:") - and not word.startswith("archived:") - and not word.startswith("shared:") + not word.startswith('tag:') + and not word.startswith('folder:') + and not word.startswith('pinned:') + and not word.startswith('archived:') + and not word.startswith('shared:') ) ] - search_text = " ".join(search_text_words) + search_text = ' '.join(search_text_words) with get_db_context(db) as db: query = db.query(Chat).filter(Chat.user_id == user_id) @@ -1196,30 +1092,32 @@ def get_chats_by_user_id_and_search_text( # Check if the database dialect is either 'sqlite' or 'postgresql' dialect_name = db.bind.dialect.name - if dialect_name == "sqlite": + if dialect_name == 'sqlite': # SQLite case: using JSON1 extension for JSON searching sqlite_content_sql = ( - "EXISTS (" - " SELECT 1 " + 'EXISTS (' + ' SELECT 1 ' " FROM json_each(Chat.chat, '$.messages') AS message " " WHERE LOWER(message.value->>'content') LIKE '%' || :content_key || '%'" - ")" + ')' ) sqlite_content_clause = text(sqlite_content_sql) query = query.filter( - or_( - Chat.title.ilike(bindparam("title_key")), sqlite_content_clause - ).params(title_key=f"%{search_text}%", content_key=search_text) + or_(Chat.title.ilike(bindparam('title_key')), sqlite_content_clause).params( + title_key=f'%{search_text}%', content_key=search_text + ) ) # Check if there are any tags to filter, it should have all the tags - if "none" in tag_ids: - query = query.filter(text(""" + if 'none' in tag_ids: + query = query.filter( + text(""" NOT EXISTS ( SELECT 1 FROM json_each(Chat.meta, '$.tags') AS tag ) - """)) + """) + ) elif tag_ids: query = query.filter( and_( @@ -1230,13 +1128,13 @@ def get_chats_by_user_id_and_search_text( FROM json_each(Chat.meta, '$.tags') AS tag WHERE tag.value = :tag_id_{tag_idx} ) - """).params(**{f"tag_id_{tag_idx}": tag_id}) + """).params(**{f'tag_id_{tag_idx}': tag_id}) for tag_idx, tag_id in enumerate(tag_ids) ] ) ) - elif dialect_name == "postgresql": + elif dialect_name == 'postgresql': # PostgreSQL doesn't allow null bytes in text. We filter those out by checking # the JSON representation for \u0000 before attempting text extraction @@ -1259,19 +1157,21 @@ def get_chats_by_user_id_and_search_text( query = query.filter( or_( - Chat.title.ilike(bindparam("title_key")), + Chat.title.ilike(bindparam('title_key')), postgres_content_clause, ) - ).params(title_key=f"%{search_text}%", content_key=search_text.lower()) + ).params(title_key=f'%{search_text}%', content_key=search_text.lower()) # Check if there are any tags to filter, it should have all the tags - if "none" in tag_ids: - query = query.filter(text(""" + if 'none' in tag_ids: + query = query.filter( + text(""" NOT EXISTS ( SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') AS tag ) - """)) + """) + ) elif tag_ids: query = query.filter( and_( @@ -1282,20 +1182,18 @@ def get_chats_by_user_id_and_search_text( FROM json_array_elements_text(Chat.meta->'tags') AS tag WHERE tag = :tag_id_{tag_idx} ) - """).params(**{f"tag_id_{tag_idx}": tag_id}) + """).params(**{f'tag_id_{tag_idx}': tag_id}) for tag_idx, tag_id in enumerate(tag_ids) ] ) ) else: - raise NotImplementedError( - f"Unsupported dialect: {db.bind.dialect.name}" - ) + raise NotImplementedError(f'Unsupported dialect: {db.bind.dialect.name}') # Perform pagination at the SQL level all_chats = query.offset(skip).limit(limit).all() - log.info(f"The number of chats: {len(all_chats)}") + log.info(f'The number of chats: {len(all_chats)}') # Validate and return chats return [ChatModel.model_validate(chat) for chat in all_chats] @@ -1327,9 +1225,7 @@ def get_chats_by_folder_ids_and_user_id( self, folder_ids: list[str], user_id: str, db: Optional[Session] = None ) -> list[ChatModel]: with get_db_context(db) as db: - query = db.query(Chat).filter( - Chat.folder_id.in_(folder_ids), Chat.user_id == user_id - ) + query = db.query(Chat).filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id) query = query.filter(or_(Chat.pinned == False, Chat.pinned == None)) query = query.filter_by(archived=False) @@ -1353,12 +1249,10 @@ def update_chat_folder_id_by_id_and_user_id( except Exception: return None - def get_chat_tags_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> list[TagModel]: + def get_chat_tags_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> list[TagModel]: with get_db_context(db) as db: chat = db.get(Chat, id) - tag_ids = chat.meta.get("tags", []) + tag_ids = chat.meta.get('tags', []) return Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db) def get_chat_list_by_user_id_and_tag_name( @@ -1371,44 +1265,38 @@ def get_chat_list_by_user_id_and_tag_name( ) -> list[ChatModel]: with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id) - tag_id = tag_name.replace(" ", "_").lower() + tag_id = tag_name.replace(' ', '_').lower() - log.info(f"DB dialect name: {db.bind.dialect.name}") - if db.bind.dialect.name == "sqlite": + log.info(f'DB dialect name: {db.bind.dialect.name}') + if db.bind.dialect.name == 'sqlite': # SQLite JSON1 querying for tags within the meta JSON field query = query.filter( - text( - f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)" - ) + text(f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)") ).params(tag_id=tag_id) - elif db.bind.dialect.name == "postgresql": + elif db.bind.dialect.name == 'postgresql': # PostgreSQL JSON query for tags within the meta JSON field (for `json` type) query = query.filter( - text( - "EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)" - ) + text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)") ).params(tag_id=tag_id) else: - raise NotImplementedError( - f"Unsupported dialect: {db.bind.dialect.name}" - ) + raise NotImplementedError(f'Unsupported dialect: {db.bind.dialect.name}') all_chats = query.all() - log.debug(f"all_chats: {all_chats}") + log.debug(f'all_chats: {all_chats}') return [ChatModel.model_validate(chat) for chat in all_chats] def add_chat_tag_by_id_and_user_id_and_tag_name( self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None ) -> Optional[ChatModel]: - tag_id = tag_name.replace(" ", "_").lower() + tag_id = tag_name.replace(' ', '_').lower() Tags.ensure_tags_exist([tag_name], user_id, db=db) try: with get_db_context(db) as db: chat = db.get(Chat, id) - if tag_id not in chat.meta.get("tags", []): + if tag_id not in chat.meta.get('tags', []): chat.meta = { **chat.meta, - "tags": list(set(chat.meta.get("tags", []) + [tag_id])), + 'tags': list(set(chat.meta.get('tags', []) + [tag_id])), } db.commit() db.refresh(chat) @@ -1416,29 +1304,21 @@ def add_chat_tag_by_id_and_user_id_and_tag_name( except Exception: return None - def count_chats_by_tag_name_and_user_id( - self, tag_name: str, user_id: str, db: Optional[Session] = None - ) -> int: + def count_chats_by_tag_name_and_user_id(self, tag_name: str, user_id: str, db: Optional[Session] = None) -> int: with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id, archived=False) - tag_id = tag_name.replace(" ", "_").lower() + tag_id = tag_name.replace(' ', '_').lower() - if db.bind.dialect.name == "sqlite": + if db.bind.dialect.name == 'sqlite': query = query.filter( - text( - "EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)" - ) + text("EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)") ).params(tag_id=tag_id) - elif db.bind.dialect.name == "postgresql": + elif db.bind.dialect.name == 'postgresql': query = query.filter( - text( - "EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)" - ) + text("EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)") ).params(tag_id=tag_id) else: - raise NotImplementedError( - f"Unsupported dialect: {db.bind.dialect.name}" - ) + raise NotImplementedError(f'Unsupported dialect: {db.bind.dialect.name}') return query.count() @@ -1467,9 +1347,7 @@ def delete_orphan_tags_for_user( orphans.append(tag_id) Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=db) - def count_chats_by_folder_id_and_user_id( - self, folder_id: str, user_id: str, db: Optional[Session] = None - ) -> int: + def count_chats_by_folder_id_and_user_id(self, folder_id: str, user_id: str, db: Optional[Session] = None) -> int: with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id) @@ -1485,28 +1363,26 @@ def delete_tag_by_id_and_user_id_and_tag_name( try: with get_db_context(db) as db: chat = db.get(Chat, id) - tags = chat.meta.get("tags", []) - tag_id = tag_name.replace(" ", "_").lower() + tags = chat.meta.get('tags', []) + tag_id = tag_name.replace(' ', '_').lower() tags = [tag for tag in tags if tag != tag_id] chat.meta = { **chat.meta, - "tags": list(set(tags)), + 'tags': list(set(tags)), } db.commit() return True except Exception: return False - def delete_all_tags_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_all_tags_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: chat = db.get(Chat, id) chat.meta = { **chat.meta, - "tags": [], + 'tags': [], } db.commit() @@ -1525,9 +1401,7 @@ def delete_chat_by_id(self, id: str, db: Optional[Session] = None) -> bool: except Exception: return False - def delete_chat_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_chat_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: db.query(ChatMessage).filter_by(chat_id=id).delete() @@ -1538,19 +1412,15 @@ def delete_chat_by_id_and_user_id( except Exception: return False - def delete_chats_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: self.delete_shared_chats_by_user_id(user_id, db=db) - chat_id_subquery = ( - db.query(Chat.id).filter_by(user_id=user_id).subquery() + chat_id_subquery = db.query(Chat.id).filter_by(user_id=user_id).subquery() + db.query(ChatMessage).filter(ChatMessage.chat_id.in_(chat_id_subquery)).delete( + synchronize_session=False ) - db.query(ChatMessage).filter( - ChatMessage.chat_id.in_(chat_id_subquery) - ).delete(synchronize_session=False) db.query(Chat).filter_by(user_id=user_id).delete() db.commit() @@ -1558,19 +1428,13 @@ def delete_chats_by_user_id( except Exception: return False - def delete_chats_by_user_id_and_folder_id( - self, user_id: str, folder_id: str, db: Optional[Session] = None - ) -> bool: + def delete_chats_by_user_id_and_folder_id(self, user_id: str, folder_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - chat_id_subquery = ( - db.query(Chat.id) - .filter_by(user_id=user_id, folder_id=folder_id) - .subquery() + chat_id_subquery = db.query(Chat.id).filter_by(user_id=user_id, folder_id=folder_id).subquery() + db.query(ChatMessage).filter(ChatMessage.chat_id.in_(chat_id_subquery)).delete( + synchronize_session=False ) - db.query(ChatMessage).filter( - ChatMessage.chat_id.in_(chat_id_subquery) - ).delete(synchronize_session=False) db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).delete() db.commit() @@ -1587,32 +1451,22 @@ def move_chats_by_user_id_and_folder_id( ) -> bool: try: with get_db_context(db) as db: - db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update( - {"folder_id": new_folder_id} - ) + db.query(Chat).filter_by(user_id=user_id, folder_id=folder_id).update({'folder_id': new_folder_id}) db.commit() return True except Exception: return False - def delete_shared_chats_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: chats_by_user = db.query(Chat).filter_by(user_id=user_id).all() - shared_chat_ids = [f"shared-{chat.id}" for chat in chats_by_user] + shared_chat_ids = [f'shared-{chat.id}' for chat in chats_by_user] # Use subquery to delete chat_messages for shared chats - shared_id_subq = ( - db.query(Chat.id) - .filter(Chat.user_id.in_(shared_chat_ids)) - .subquery() - ) - db.query(ChatMessage).filter( - ChatMessage.chat_id.in_(shared_id_subq) - ).delete(synchronize_session=False) + shared_id_subq = db.query(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)).subquery() + db.query(ChatMessage).filter(ChatMessage.chat_id.in_(shared_id_subq)).delete(synchronize_session=False) db.query(Chat).filter(Chat.user_id.in_(shared_chat_ids)).delete() db.commit() @@ -1632,21 +1486,10 @@ def insert_chat_files( return None chat_message_file_ids = [ - item.id - for item in self.get_chat_files_by_chat_id_and_message_id( - chat_id, message_id, db=db - ) + item.id for item in self.get_chat_files_by_chat_id_and_message_id(chat_id, message_id, db=db) ] # Remove duplicates and existing file_ids - file_ids = list( - set( - [ - file_id - for file_id in file_ids - if file_id and file_id not in chat_message_file_ids - ] - ) - ) + file_ids = list(set([file_id for file_id in file_ids if file_id and file_id not in chat_message_file_ids])) if not file_ids: return None @@ -1667,9 +1510,7 @@ def insert_chat_files( for file_id in file_ids ] - results = [ - ChatFile(**chat_file.model_dump()) for chat_file in chat_files - ] + results = [ChatFile(**chat_file.model_dump()) for chat_file in chat_files] db.add_all(results) db.commit() @@ -1688,13 +1529,9 @@ def get_chat_files_by_chat_id_and_message_id( .order_by(ChatFile.created_at.asc()) .all() ) - return [ - ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files - ] + return [ChatFileModel.model_validate(chat_file) for chat_file in all_chat_files] - def delete_chat_file( - self, chat_id: str, file_id: str, db: Optional[Session] = None - ) -> bool: + def delete_chat_file(self, chat_id: str, file_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: db.query(ChatFile).filter_by(chat_id=chat_id, file_id=file_id).delete() @@ -1703,9 +1540,7 @@ def delete_chat_file( except Exception: return False - def get_shared_chats_by_file_id( - self, file_id: str, db: Optional[Session] = None - ) -> list[ChatModel]: + def get_shared_chats_by_file_id(self, file_id: str, db: Optional[Session] = None) -> list[ChatModel]: with get_db_context(db) as db: # Join Chat and ChatFile tables to get shared chats associated with the file_id all_chats = ( diff --git a/backend/open_webui/models/credits.py b/backend/open_webui/models/credits.py index b7a0e5cea6..3f28604274 100644 --- a/backend/open_webui/models/credits.py +++ b/backend/open_webui/models/credits.py @@ -22,7 +22,7 @@ class Credit(Base): - __tablename__ = "credit" + __tablename__ = 'credit' id = Column(String, primary_key=True) user_id = Column(String, unique=True, nullable=False) @@ -33,7 +33,7 @@ class Credit(Base): class CreditLog(Base): - __tablename__ = "credit_log" + __tablename__ = 'credit_log' id = Column(String, primary_key=True) user_id = Column(String, index=True, nullable=False) @@ -44,7 +44,7 @@ class CreditLog(Base): class TradeTicket(Base): - __tablename__ = "trade_ticket" + __tablename__ = 'trade_ticket' id = Column(String, primary_key=True) user_id = Column(String, index=True, nullable=False) @@ -55,7 +55,7 @@ class TradeTicket(Base): class RedemptionCode(Base): - __tablename__ = "redemption_code" + __tablename__ = 'redemption_code' code = Column(String, primary_key=True) purpose = Column(String, index=True) @@ -76,7 +76,7 @@ class CreditModel(BaseModel): model_config = ConfigDict(from_attributes=True) id: str = Field(default_factory=lambda: uuid.uuid4().hex) user_id: str - credit: Decimal = Field(default_factory=lambda: Decimal("0")) + credit: Decimal = Field(default_factory=lambda: Decimal('0')) updated_at: int = Field(default_factory=lambda: int(time.time())) created_at: int = Field(default_factory=lambda: int(time.time())) @@ -85,13 +85,13 @@ class CreditLogModel(BaseModel): model_config = ConfigDict(from_attributes=True) id: str = Field(default_factory=lambda: uuid.uuid4().hex) user_id: str - credit: Decimal = Field(default_factory=lambda: Decimal("0")) + credit: Decimal = Field(default_factory=lambda: Decimal('0')) detail: dict = Field(default_factory=lambda: {}) created_at: int = Field(default_factory=lambda: int(time.time())) class CreditLogUsage(BaseModel): - model_config = ConfigDict(from_attributes=True, extra="allow") + model_config = ConfigDict(from_attributes=True, extra='allow') total_price: Optional[Decimal] = None prompt_unit_price: Optional[Decimal] = None completion_unit_price: Optional[Decimal] = None @@ -115,7 +115,7 @@ class CreditLogSimpleDetailAPIParams(BaseModel): class CreditLogSimpleDetail(BaseModel): model_config = ConfigDict(from_attributes=True) - desc: str = Field(default_factory=lambda: "") + desc: str = Field(default_factory=lambda: '') api_params: CreditLogSimpleDetailAPIParams = Field(default_factory=lambda: {}) usage: CreditLogUsage = Field(default_factory=lambda: {}) @@ -123,13 +123,13 @@ class CreditLogSimpleDetail(BaseModel): class CreditLogSimpleModel(CreditLogModel): model_config = ConfigDict(from_attributes=True) detail: CreditLogSimpleDetail - username: Optional[str] = Field(default="") + username: Optional[str] = Field(default='') class SetCreditFormDetail(BaseModel): - api_path: str = Field(default="") + api_path: str = Field(default='') api_params: dict = Field(default_factory=lambda: {}) - desc: str = Field(default="") + desc: str = Field(default='') usage: dict = Field(default_factory=lambda: {}) @@ -149,13 +149,13 @@ class TradeTicketModel(BaseModel): model_config = ConfigDict(from_attributes=True) id: str = Field(default_factory=lambda: uuid.uuid4().hex) user_id: str - amount: Decimal = Field(default_factory=lambda: Decimal("0")) + amount: Decimal = Field(default_factory=lambda: Decimal('0')) detail: dict = Field(default_factory=lambda: {}) created_at: int = Field(default_factory=lambda: int(time.time())) class RedemptionCodeModel(BaseModel): - model_config = ConfigDict(from_attributes=True, extra="allow") + model_config = ConfigDict(from_attributes=True, extra='allow') code: str purpose: str user_id: Optional[str] = None @@ -175,9 +175,7 @@ def insert_new_credit(self, user_id: str) -> Optional[CreditModel]: from open_webui.config import CREDIT_DEFAULT_CREDIT try: - credit_model = CreditModel( - user_id=user_id, credit=Decimal(CREDIT_DEFAULT_CREDIT.value) - ) + credit_model = CreditModel(user_id=user_id, credit=Decimal(CREDIT_DEFAULT_CREDIT.value)) with get_db() as db: result = Credit(**credit_model.model_dump()) db.add(result) @@ -190,12 +188,10 @@ def insert_new_credit(self, user_id: str) -> Optional[CreditModel]: return None def init_credit_by_user_id(self, user_id: str) -> CreditModel: - credit_model = self.get_credit_by_user_id( - user_id=user_id - ) or self.insert_new_credit(user_id=user_id) + credit_model = self.get_credit_by_user_id(user_id=user_id) or self.insert_new_credit(user_id=user_id) if credit_model is not None: return credit_model - raise HTTPException(status_code=500, detail="credit initialize failed") + raise HTTPException(status_code=500, detail='credit initialize failed') def get_credit_by_user_id(self, user_id: str) -> Optional[CreditModel]: try: @@ -223,7 +219,7 @@ def set_credit_by_user_id(self, form_data: SetCreditForm) -> CreditModel: with get_db() as db: db.add(CreditLog(**log.model_dump())) db.query(Credit).filter(Credit.user_id == credit_model.user_id).update( - {"credit": form_data.credit, "updated_at": int(time.time())}, + {'credit': form_data.credit, 'updated_at': int(time.time())}, synchronize_session=False, ) db.commit() @@ -240,8 +236,8 @@ def add_credit_by_user_id(self, form_data: AddCreditForm) -> Optional[CreditMode db.add(CreditLog(**log.model_dump())) db.query(Credit).filter(Credit.user_id == form_data.user_id).update( { - "credit": Credit.credit + form_data.amount, - "updated_at": int(time.time()), + 'credit': Credit.credit + form_data.amount, + 'updated_at': int(time.time()), }, synchronize_session=False, ) @@ -253,9 +249,7 @@ def add_credit_by_user_id(self, form_data: AddCreditForm) -> Optional[CreditMode class TradeTicketTable: - def insert_new_ticket( - self, id: str, user_id: str, amount: float, detail: dict - ) -> TradeTicketModel: + def insert_new_ticket(self, id: str, user_id: str, amount: float, detail: dict) -> TradeTicketModel: try: ticket = TradeTicketModel( id=id, @@ -300,16 +294,14 @@ def update_credit_by_id(self, id: str, detail: dict) -> None: try: with get_db() as db: - db.query(TradeTicket).filter(TradeTicket.id == id).update( - {"detail": detail} - ) + db.query(TradeTicket).filter(TradeTicket.id == id).update({'detail': detail}) db.commit() ticket = self.get_ticket_by_id(id) Credits.add_credit_by_user_id( AddCreditForm( user_id=ticket.user_id, amount=ticket.amount * Decimal(CREDIT_EXCHANGE_RATIO.value), - detail=SetCreditFormDetail(desc="payment success"), + detail=SetCreditFormDetail(desc='payment success'), ) ) return None @@ -381,14 +373,8 @@ class RedemptionCodeTable: def get_code(self, code: str) -> Optional[RedemptionCodeModel]: try: with get_db() as db: - redemption_code = ( - db.query(RedemptionCode).filter(RedemptionCode.code == code).first() - ) - return ( - RedemptionCodeModel.model_validate(redemption_code) - if redemption_code - else None - ) + redemption_code = db.query(RedemptionCode).filter(RedemptionCode.code == code).first() + return RedemptionCodeModel.model_validate(redemption_code) if redemption_code else None except Exception: return None @@ -409,16 +395,12 @@ def get_codes( query = query.offset(offset) if limit: query = query.limit(limit) - return total, [ - RedemptionCodeModel.model_validate(code) for code in query.all() - ] + return total, [RedemptionCodeModel.model_validate(code) for code in query.all()] def insert_codes(self, redemption_codes: List[RedemptionCodeModel]) -> None: try: with get_db() as db: - db.add_all( - [RedemptionCode(**code.model_dump()) for code in redemption_codes] - ) + db.add_all([RedemptionCode(**code.model_dump()) for code in redemption_codes]) db.commit() return None except Exception as err: @@ -427,13 +409,11 @@ def insert_codes(self, redemption_codes: List[RedemptionCodeModel]) -> None: def update_code(self, code: RedemptionCodeModel) -> None: try: with get_db() as db: - db.query(RedemptionCode).filter( - RedemptionCode.code == code.code - ).update( + db.query(RedemptionCode).filter(RedemptionCode.code == code.code).update( { - "purpose": code.purpose, - "amount": code.amount, - "expired_at": code.expired_at, + 'purpose': code.purpose, + 'amount': code.amount, + 'expired_at': code.expired_at, } ) db.commit() @@ -456,34 +436,29 @@ def receive_code(self, code: str, user_id: str) -> None: # load code redemption_code = self.get_code(code) if redemption_code is None: - raise HTTPException(status_code=404, detail="Code not found") + raise HTTPException(status_code=404, detail='Code not found') # check if code is received if redemption_code.user_id is not None: - raise HTTPException(status_code=400, detail="Code already received") + raise HTTPException(status_code=400, detail='Code already received') # check expired now = int(time.time()) - if ( - redemption_code.expired_at is not None - and redemption_code.expired_at < now - ): - raise HTTPException(status_code=400, detail="Code expired") + if redemption_code.expired_at is not None and redemption_code.expired_at < now: + raise HTTPException(status_code=400, detail='Code expired') # concurrency control - cache_key = f"redemption_code:{code}" + cache_key = f'redemption_code:{code}' redis = get_redis_connection( redis_url=REDIS_URL, - redis_sentinels=get_sentinels_from_env( - REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT - ), + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), redis_cluster=REDIS_CLUSTER, ) if not redis.set(cache_key, cache_key, nx=True, ex=60): - raise HTTPException(status_code=400, detail="Too many requests") + raise HTTPException(status_code=400, detail='Too many requests') # receive with get_db() as db: db.query(RedemptionCode).filter(RedemptionCode.code == code).update( { - "user_id": user_id, - "received_at": int(time.time()), + 'user_id': user_id, + 'received_at': int(time.time()), } ) db.commit() @@ -491,9 +466,7 @@ def receive_code(self, code: str, user_id: str) -> None: AddCreditForm( user_id=user_id, amount=redemption_code.amount, - detail=SetCreditFormDetail( - desc="redemption code received", api_params={"code": code} - ), + detail=SetCreditFormDetail(desc='redemption code received', api_params={'code': code}), ) ) return diff --git a/backend/open_webui/models/feedbacks.py b/backend/open_webui/models/feedbacks.py index 406adb2559..aa6c7bdcae 100644 --- a/backend/open_webui/models/feedbacks.py +++ b/backend/open_webui/models/feedbacks.py @@ -19,7 +19,7 @@ class Feedback(Base): - __tablename__ = "feedback" + __tablename__ = 'feedback' id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) version = Column(BigInteger, default=0) @@ -81,7 +81,7 @@ class RatingData(BaseModel): sibling_model_ids: Optional[list[str]] = None reason: Optional[str] = None comment: Optional[str] = None - model_config = ConfigDict(extra="allow", protected_namespaces=()) + model_config = ConfigDict(extra='allow', protected_namespaces=()) class MetaData(BaseModel): @@ -89,12 +89,12 @@ class MetaData(BaseModel): chat_id: Optional[str] = None message_id: Optional[str] = None tags: Optional[list[str]] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class SnapshotData(BaseModel): chat: Optional[dict] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class FeedbackForm(BaseModel): @@ -102,14 +102,14 @@ class FeedbackForm(BaseModel): data: Optional[RatingData] = None meta: Optional[dict] = None snapshot: Optional[SnapshotData] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class UserResponse(BaseModel): id: str name: str email: str - role: str = "pending" + role: str = 'pending' last_active_at: int # timestamp in epoch updated_at: int # timestamp in epoch @@ -146,12 +146,12 @@ def insert_new_feedback( id = str(uuid.uuid4()) feedback = FeedbackModel( **{ - "id": id, - "user_id": user_id, - "version": 0, + 'id': id, + 'user_id': user_id, + 'version': 0, **form_data.model_dump(), - "created_at": int(time.time()), - "updated_at": int(time.time()), + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) try: @@ -164,12 +164,10 @@ def insert_new_feedback( else: return None except Exception as e: - log.exception(f"Error creating a new feedback: {e}") + log.exception(f'Error creating a new feedback: {e}') return None - def get_feedback_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[FeedbackModel]: + def get_feedback_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FeedbackModel]: try: with get_db_context(db) as db: feedback = db.query(Feedback).filter_by(id=id).first() @@ -191,16 +189,14 @@ def get_feedback_by_id_and_user_id( except Exception: return None - def get_feedbacks_by_chat_id( - self, chat_id: str, db: Optional[Session] = None - ) -> list[FeedbackModel]: + def get_feedbacks_by_chat_id(self, chat_id: str, db: Optional[Session] = None) -> list[FeedbackModel]: """Get all feedbacks for a specific chat.""" try: with get_db_context(db) as db: # meta.chat_id stores the chat reference feedbacks = ( db.query(Feedback) - .filter(Feedback.meta["chat_id"].as_string() == chat_id) + .filter(Feedback.meta['chat_id'].as_string() == chat_id) .order_by(Feedback.created_at.desc()) .all() ) @@ -219,36 +215,28 @@ def get_feedback_items( query = db.query(Feedback, User).join(User, Feedback.user_id == User.id) if filter: - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') - if order_by == "username": - if direction == "asc": + if order_by == 'username': + if direction == 'asc': query = query.order_by(User.name.asc()) else: query = query.order_by(User.name.desc()) - elif order_by == "model_id": + elif order_by == 'model_id': # it's stored in feedback.data['model_id'] - if direction == "asc": - query = query.order_by( - Feedback.data["model_id"].as_string().asc() - ) + if direction == 'asc': + query = query.order_by(Feedback.data['model_id'].as_string().asc()) else: - query = query.order_by( - Feedback.data["model_id"].as_string().desc() - ) - elif order_by == "rating": + query = query.order_by(Feedback.data['model_id'].as_string().desc()) + elif order_by == 'rating': # it's stored in feedback.data['rating'] - if direction == "asc": - query = query.order_by( - Feedback.data["rating"].as_string().asc() - ) + if direction == 'asc': + query = query.order_by(Feedback.data['rating'].as_string().asc()) else: - query = query.order_by( - Feedback.data["rating"].as_string().desc() - ) - elif order_by == "updated_at": - if direction == "asc": + query = query.order_by(Feedback.data['rating'].as_string().desc()) + elif order_by == 'updated_at': + if direction == 'asc': query = query.order_by(Feedback.updated_at.asc()) else: query = query.order_by(Feedback.updated_at.desc()) @@ -270,9 +258,7 @@ def get_feedback_items( for feedback, user in items: feedback_model = FeedbackModel.model_validate(feedback) user_model = UserResponse.model_validate(user) - feedbacks.append( - FeedbackUserResponse(**feedback_model.model_dump(), user=user_model) - ) + feedbacks.append(FeedbackUserResponse(**feedback_model.model_dump(), user=user_model)) return FeedbackListResponse(items=feedbacks, total=total) @@ -280,14 +266,10 @@ def get_all_feedbacks(self, db: Optional[Session] = None) -> list[FeedbackModel] with get_db_context(db) as db: return [ FeedbackModel.model_validate(feedback) - for feedback in db.query(Feedback) - .order_by(Feedback.updated_at.desc()) - .all() + for feedback in db.query(Feedback).order_by(Feedback.updated_at.desc()).all() ] - def get_all_feedback_ids( - self, db: Optional[Session] = None - ) -> list[FeedbackIdResponse]: + def get_all_feedback_ids(self, db: Optional[Session] = None) -> list[FeedbackIdResponse]: with get_db_context(db) as db: return [ FeedbackIdResponse( @@ -306,14 +288,11 @@ def get_all_feedback_ids( .all() ] - def get_feedbacks_for_leaderboard( - self, db: Optional[Session] = None - ) -> list[LeaderboardFeedbackData]: + def get_feedbacks_for_leaderboard(self, db: Optional[Session] = None) -> list[LeaderboardFeedbackData]: """Fetch only id and data for leaderboard computation (excludes snapshot/meta).""" with get_db_context(db) as db: return [ - LeaderboardFeedbackData(id=row.id, data=row.data) - for row in db.query(Feedback.id, Feedback.data).all() + LeaderboardFeedbackData(id=row.id, data=row.data) for row in db.query(Feedback.id, Feedback.data).all() ] def get_model_evaluation_history( @@ -333,30 +312,26 @@ def get_model_evaluation_history( rows = db.query(Feedback.created_at, Feedback.data).all() else: cutoff = int(time.time()) - (days * 86400) - rows = ( - db.query(Feedback.created_at, Feedback.data) - .filter(Feedback.created_at >= cutoff) - .all() - ) + rows = db.query(Feedback.created_at, Feedback.data).filter(Feedback.created_at >= cutoff).all() - daily_counts = defaultdict(lambda: {"won": 0, "lost": 0}) + daily_counts = defaultdict(lambda: {'won': 0, 'lost': 0}) first_date = None for created_at, data in rows: if not data: continue - if data.get("model_id") != model_id: + if data.get('model_id') != model_id: continue - rating_str = str(data.get("rating", "")) - if rating_str not in ("1", "-1"): + rating_str = str(data.get('rating', '')) + if rating_str not in ('1', '-1'): continue - date_str = datetime.fromtimestamp(created_at).strftime("%Y-%m-%d") - if rating_str == "1": - daily_counts[date_str]["won"] += 1 + date_str = datetime.fromtimestamp(created_at).strftime('%Y-%m-%d') + if rating_str == '1': + daily_counts[date_str]['won'] += 1 else: - daily_counts[date_str]["lost"] += 1 + daily_counts[date_str]['lost'] += 1 # Track first date for this model if first_date is None or date_str < first_date: @@ -368,7 +343,7 @@ def get_model_evaluation_history( if days == 0 and first_date: # All time: start from first feedback date - start_date = datetime.strptime(first_date, "%Y-%m-%d").date() + start_date = datetime.strptime(first_date, '%Y-%m-%d').date() num_days = (today - start_date).days + 1 else: # Fixed range @@ -377,36 +352,24 @@ def get_model_evaluation_history( for i in range(num_days): d = start_date + timedelta(days=i) - date_str = d.strftime("%Y-%m-%d") - counts = daily_counts.get(date_str, {"won": 0, "lost": 0}) - result.append( - ModelHistoryEntry(date=date_str, won=counts["won"], lost=counts["lost"]) - ) + date_str = d.strftime('%Y-%m-%d') + counts = daily_counts.get(date_str, {'won': 0, 'lost': 0}) + result.append(ModelHistoryEntry(date=date_str, won=counts['won'], lost=counts['lost'])) return result - def get_feedbacks_by_type( - self, type: str, db: Optional[Session] = None - ) -> list[FeedbackModel]: + def get_feedbacks_by_type(self, type: str, db: Optional[Session] = None) -> list[FeedbackModel]: with get_db_context(db) as db: return [ FeedbackModel.model_validate(feedback) - for feedback in db.query(Feedback) - .filter_by(type=type) - .order_by(Feedback.updated_at.desc()) - .all() + for feedback in db.query(Feedback).filter_by(type=type).order_by(Feedback.updated_at.desc()).all() ] - def get_feedbacks_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[FeedbackModel]: + def get_feedbacks_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[FeedbackModel]: with get_db_context(db) as db: return [ FeedbackModel.model_validate(feedback) - for feedback in db.query(Feedback) - .filter_by(user_id=user_id) - .order_by(Feedback.updated_at.desc()) - .all() + for feedback in db.query(Feedback).filter_by(user_id=user_id).order_by(Feedback.updated_at.desc()).all() ] def update_feedback_by_id( @@ -462,9 +425,7 @@ def delete_feedback_by_id(self, id: str, db: Optional[Session] = None) -> bool: db.commit() return True - def delete_feedback_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_feedback_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: feedback = db.query(Feedback).filter_by(id=id, user_id=user_id).first() if not feedback: @@ -473,9 +434,7 @@ def delete_feedback_by_id_and_user_id( db.commit() return True - def delete_feedbacks_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_feedbacks_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: result = db.query(Feedback).filter_by(user_id=user_id).delete() db.commit() diff --git a/backend/open_webui/models/files.py b/backend/open_webui/models/files.py index 84dd43f5e8..9a5b8fa400 100644 --- a/backend/open_webui/models/files.py +++ b/backend/open_webui/models/files.py @@ -12,11 +12,13 @@ #################### # Files DB Schema +# What is written here bears witness. Let the testimony +# remain as it was given, and let none tamper with it. #################### class File(Base): - __tablename__ = "file" + __tablename__ = 'file' id = Column(String, primary_key=True, unique=True) user_id = Column(String) hash = Column(Text, nullable=True) @@ -58,9 +60,9 @@ class FileMeta(BaseModel): content_type: Optional[str] = None size: Optional[int] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') - @model_validator(mode="before") + @model_validator(mode='before') @classmethod def sanitize_meta(cls, data): """Sanitize metadata fields to handle malformed legacy data.""" @@ -68,14 +70,12 @@ def sanitize_meta(cls, data): return data # Handle content_type that may be a list like ['application/pdf', None] - content_type = data.get("content_type") + content_type = data.get('content_type') if isinstance(content_type, list): # Extract first non-None string value - data["content_type"] = next( - (item for item in content_type if isinstance(item, str)), None - ) + data['content_type'] = next((item for item in content_type if isinstance(item, str)), None) elif content_type is not None and not isinstance(content_type, str): - data["content_type"] = None + data['content_type'] = None return data @@ -92,7 +92,7 @@ class FileModelResponse(BaseModel): created_at: int # timestamp in epoch updated_at: Optional[int] = None # timestamp in epoch, optional for legacy files - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class FileMetadataResponse(BaseModel): @@ -103,6 +103,11 @@ class FileMetadataResponse(BaseModel): updated_at: int # timestamp in epoch +class FileListResponse(BaseModel): + items: list[FileModelResponse] + total: int + + class FileForm(BaseModel): id: str hash: Optional[str] = None @@ -118,29 +123,22 @@ class FileUpdateForm(BaseModel): meta: Optional[dict] = None -class FileListResponse(BaseModel): - items: list[FileModel] - total: int - - class FilesTable: - def insert_new_file( - self, user_id: str, form_data: FileForm, db: Optional[Session] = None - ) -> Optional[FileModel]: + def insert_new_file(self, user_id: str, form_data: FileForm, db: Optional[Session] = None) -> Optional[FileModel]: with get_db_context(db) as db: file_data = form_data.model_dump() # Sanitize meta to remove non-JSON-serializable objects # (e.g. callable tool functions, MCP client instances from middleware) - if file_data.get("meta"): - file_data["meta"] = sanitize_metadata(file_data["meta"]) + if file_data.get('meta'): + file_data['meta'] = sanitize_metadata(file_data['meta']) file = FileModel( **{ **file_data, - "user_id": user_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), + 'user_id': user_id, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) @@ -154,12 +152,10 @@ def insert_new_file( else: return None except Exception as e: - log.exception(f"Error inserting a new file: {e}") + log.exception(f'Error inserting a new file: {e}') return None - def get_file_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[FileModel]: + def get_file_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FileModel]: try: with get_db_context(db) as db: try: @@ -170,9 +166,7 @@ def get_file_by_id( except Exception: return None - def get_file_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> Optional[FileModel]: + def get_file_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[FileModel]: with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id, user_id=user_id).first() @@ -183,9 +177,7 @@ def get_file_by_id_and_user_id( except Exception: return None - def get_file_metadata_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[FileMetadataResponse]: + def get_file_metadata_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FileMetadataResponse]: with get_db_context(db) as db: try: file = db.get(File, id) @@ -203,9 +195,7 @@ def get_files(self, db: Optional[Session] = None) -> list[FileModel]: with get_db_context(db) as db: return [FileModel.model_validate(file) for file in db.query(File).all()] - def check_access_by_user_id( - self, id, user_id, permission="write", db: Optional[Session] = None - ) -> bool: + def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[Session] = None) -> bool: file = self.get_file_by_id(id, db=db) if not file: return False @@ -214,21 +204,14 @@ def check_access_by_user_id( # Implement additional access control logic here as needed return False - def get_files_by_ids( - self, ids: list[str], db: Optional[Session] = None - ) -> list[FileModel]: + def get_files_by_ids(self, ids: list[str], db: Optional[Session] = None) -> list[FileModel]: with get_db_context(db) as db: return [ FileModel.model_validate(file) - for file in db.query(File) - .filter(File.id.in_(ids)) - .order_by(File.updated_at.desc()) - .all() + for file in db.query(File).filter(File.id.in_(ids)).order_by(File.updated_at.desc()).all() ] - def get_file_metadatas_by_ids( - self, ids: list[str], db: Optional[Session] = None - ) -> list[FileMetadataResponse]: + def get_file_metadatas_by_ids(self, ids: list[str], db: Optional[Session] = None) -> list[FileMetadataResponse]: with get_db_context(db) as db: return [ FileMetadataResponse( @@ -238,23 +221,37 @@ def get_file_metadatas_by_ids( created_at=file.created_at, updated_at=file.updated_at, ) - for file in db.query( - File.id, File.hash, File.meta, File.created_at, File.updated_at - ) + for file in db.query(File.id, File.hash, File.meta, File.created_at, File.updated_at) .filter(File.id.in_(ids)) .order_by(File.updated_at.desc()) .all() ] - def get_files_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[FileModel]: + def get_files_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[FileModel]: with get_db_context(db) as db: - return [ + return [FileModel.model_validate(file) for file in db.query(File).filter_by(user_id=user_id).all()] + + def get_file_list( + self, + user_id: Optional[str] = None, + skip: int = 0, + limit: int = 50, + db: Optional[Session] = None, + ) -> 'FileListResponse': + with get_db_context(db) as db: + query = db.query(File) + if user_id: + query = query.filter_by(user_id=user_id) + + total = query.count() + + items = [ FileModel.model_validate(file) - for file in db.query(File).filter_by(user_id=user_id).all() + for file in query.order_by(File.updated_at.desc(), File.id.desc()).offset(skip).limit(limit).all() ] + return FileListResponse(items=items, total=total) + @staticmethod def _glob_to_like_pattern(glob: str) -> str: """ @@ -271,17 +268,17 @@ def _glob_to_like_pattern(glob: str) -> str: A SQL LIKE compatible pattern with proper escaping. """ # Escape SQL special characters first, then convert glob wildcards - pattern = glob.replace("\\", "\\\\") - pattern = pattern.replace("%", "\\%") - pattern = pattern.replace("_", "\\_") - pattern = pattern.replace("*", "%") - pattern = pattern.replace("?", "_") + pattern = glob.replace('\\', '\\\\') + pattern = pattern.replace('%', '\\%') + pattern = pattern.replace('_', '\\_') + pattern = pattern.replace('*', '%') + pattern = pattern.replace('?', '_') return pattern def search_files( self, user_id: Optional[str] = None, - filename: str = "*", + filename: str = '*', skip: int = 0, limit: int = 100, db: Optional[Session] = None, @@ -306,15 +303,12 @@ def search_files( query = query.filter_by(user_id=user_id) pattern = self._glob_to_like_pattern(filename) - if pattern != "%": - query = query.filter(File.filename.ilike(pattern, escape="\\")) + if pattern != '%': + query = query.filter(File.filename.ilike(pattern, escape='\\')) return [ FileModel.model_validate(file) - for file in query.order_by(File.created_at.desc(), File.id.desc()) - .offset(skip) - .limit(limit) - .all() + for file in query.order_by(File.created_at.desc(), File.id.desc()).offset(skip).limit(limit).all() ] def update_file_by_id( @@ -337,12 +331,10 @@ def update_file_by_id( db.commit() return FileModel.model_validate(file) except Exception as e: - log.exception(f"Error updating file completely by id: {e}") + log.exception(f'Error updating file completely by id: {e}') return None - def update_file_hash_by_id( - self, id: str, hash: Optional[str], db: Optional[Session] = None - ) -> Optional[FileModel]: + def update_file_hash_by_id(self, id: str, hash: Optional[str], db: Optional[Session] = None) -> Optional[FileModel]: with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id).first() @@ -354,9 +346,7 @@ def update_file_hash_by_id( except Exception: return None - def update_file_data_by_id( - self, id: str, data: dict, db: Optional[Session] = None - ) -> Optional[FileModel]: + def update_file_data_by_id(self, id: str, data: dict, db: Optional[Session] = None) -> Optional[FileModel]: with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id).first() @@ -365,12 +355,9 @@ def update_file_data_by_id( db.commit() return FileModel.model_validate(file) except Exception as e: - return None - def update_file_metadata_by_id( - self, id: str, meta: dict, db: Optional[Session] = None - ) -> Optional[FileModel]: + def update_file_metadata_by_id(self, id: str, meta: dict, db: Optional[Session] = None) -> Optional[FileModel]: with get_db_context(db) as db: try: file = db.query(File).filter_by(id=id).first() diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index b491b831f2..cd9c9bbc67 100644 --- a/backend/open_webui/models/folders.py +++ b/backend/open_webui/models/folders.py @@ -16,11 +16,13 @@ #################### # Folder DB Schema +# Let every room in this house shelter someone who needs it, +# and let no chamber stand empty while there is want. #################### class Folder(Base): - __tablename__ = "folder" + __tablename__ = 'folder' id = Column(Text, primary_key=True, unique=True) parent_id = Column(Text, nullable=True) user_id = Column(Text) @@ -72,14 +74,14 @@ class FolderForm(BaseModel): data: Optional[dict] = None meta: Optional[dict] = None parent_id: Optional[str] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class FolderUpdateForm(BaseModel): name: Optional[str] = None data: Optional[dict] = None meta: Optional[dict] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class FolderTable: @@ -94,12 +96,12 @@ def insert_new_folder( id = str(uuid.uuid4()) folder = FolderModel( **{ - "id": id, - "user_id": user_id, + 'id': id, + 'user_id': user_id, **(form_data.model_dump(exclude_unset=True) or {}), - "parent_id": parent_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), + 'parent_id': parent_id, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) try: @@ -112,7 +114,7 @@ def insert_new_folder( else: return None except Exception as e: - log.exception(f"Error inserting a new folder: {e}") + log.exception(f'Error inserting a new folder: {e}') return None def get_folder_by_id_and_user_id( @@ -137,9 +139,7 @@ def get_children_folders_by_id_and_user_id( folders = [] def get_children(folder): - children = self.get_folders_by_parent_id_and_user_id( - folder.id, user_id, db=db - ) + children = self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) for child in children: get_children(child) folders.append(child) @@ -153,14 +153,9 @@ def get_children(folder): except Exception: return None - def get_folders_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[FolderModel]: + def get_folders_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[FolderModel]: with get_db_context(db) as db: - return [ - FolderModel.model_validate(folder) - for folder in db.query(Folder).filter_by(user_id=user_id).all() - ] + return [FolderModel.model_validate(folder) for folder in db.query(Folder).filter_by(user_id=user_id).all()] def get_folder_by_parent_id_and_user_id_and_name( self, @@ -184,7 +179,7 @@ def get_folder_by_parent_id_and_user_id_and_name( return FolderModel.model_validate(folder) except Exception as e: - log.error(f"get_folder_by_parent_id_and_user_id_and_name: {e}") + log.error(f'get_folder_by_parent_id_and_user_id_and_name: {e}') return None def get_folders_by_parent_id_and_user_id( @@ -193,9 +188,7 @@ def get_folders_by_parent_id_and_user_id( with get_db_context(db) as db: return [ FolderModel.model_validate(folder) - for folder in db.query(Folder) - .filter_by(parent_id=parent_id, user_id=user_id) - .all() + for folder in db.query(Folder).filter_by(parent_id=parent_id, user_id=user_id).all() ] def update_folder_parent_id_by_id_and_user_id( @@ -219,7 +212,7 @@ def update_folder_parent_id_by_id_and_user_id( return FolderModel.model_validate(folder) except Exception as e: - log.error(f"update_folder: {e}") + log.error(f'update_folder: {e}') return def update_folder_by_id_and_user_id( @@ -241,7 +234,7 @@ def update_folder_by_id_and_user_id( existing_folder = ( db.query(Folder) .filter_by( - name=form_data.get("name"), + name=form_data.get('name'), parent_id=folder.parent_id, user_id=user_id, ) @@ -251,17 +244,17 @@ def update_folder_by_id_and_user_id( if existing_folder and existing_folder.id != id: return None - folder.name = form_data.get("name", folder.name) - if "data" in form_data: + folder.name = form_data.get('name', folder.name) + if 'data' in form_data: folder.data = { **(folder.data or {}), - **form_data["data"], + **form_data['data'], } - if "meta" in form_data: + if 'meta' in form_data: folder.meta = { **(folder.meta or {}), - **form_data["meta"], + **form_data['meta'], } folder.updated_at = int(time.time()) @@ -269,7 +262,7 @@ def update_folder_by_id_and_user_id( return FolderModel.model_validate(folder) except Exception as e: - log.error(f"update_folder: {e}") + log.error(f'update_folder: {e}') return def update_folder_is_expanded_by_id_and_user_id( @@ -289,12 +282,10 @@ def update_folder_is_expanded_by_id_and_user_id( return FolderModel.model_validate(folder) except Exception as e: - log.error(f"update_folder: {e}") + log.error(f'update_folder: {e}') return - def delete_folder_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> list[str]: + def delete_folder_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> list[str]: try: folder_ids = [] with get_db_context(db) as db: @@ -306,11 +297,8 @@ def delete_folder_by_id_and_user_id( # Delete all children folders def delete_children(folder): - folder_children = self.get_folders_by_parent_id_and_user_id( - folder.id, user_id, db=db - ) + folder_children = self.get_folders_by_parent_id_and_user_id(folder.id, user_id, db=db) for folder_child in folder_children: - delete_children(folder_child) folder_ids.append(folder_child.id) @@ -323,12 +311,12 @@ def delete_children(folder): db.commit() return folder_ids except Exception as e: - log.error(f"delete_folder: {e}") + log.error(f'delete_folder: {e}') return [] def normalize_folder_name(self, name: str) -> str: # Replace _ and space with a single space, lower case, collapse multiple spaces - name = re.sub(r"[\s_]+", " ", name) + name = re.sub(r'[\s_]+', ' ', name) return name.strip().lower() def search_folders_by_names( @@ -349,9 +337,7 @@ def search_folders_by_names( results[folder.id] = FolderModel.model_validate(folder) # get children folders - children = self.get_children_folders_by_id_and_user_id( - folder.id, user_id, db=db - ) + children = self.get_children_folders_by_id_and_user_id(folder.id, user_id, db=db) for child in children: results[child.id] = child diff --git a/backend/open_webui/models/functions.py b/backend/open_webui/models/functions.py index 18916315e6..f9761e947a 100644 --- a/backend/open_webui/models/functions.py +++ b/backend/open_webui/models/functions.py @@ -2,9 +2,9 @@ import time from typing import Optional -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, defer from open_webui.internal.db import Base, JSONField, get_db, get_db_context -from open_webui.models.users import Users, UserModel +from open_webui.models.users import Users, UserModel, UserResponse from pydantic import BaseModel, ConfigDict from sqlalchemy import BigInteger, Boolean, Column, String, Text, Index @@ -12,11 +12,13 @@ #################### # Functions DB Schema +# Each function here is a promise made. Let no promise +# go unkept, and let none be called who cannot answer. #################### class Function(Base): - __tablename__ = "function" + __tablename__ = 'function' id = Column(String, primary_key=True, unique=True) user_id = Column(String) @@ -30,13 +32,13 @@ class Function(Base): updated_at = Column(BigInteger) created_at = Column(BigInteger) - __table_args__ = (Index("is_global_idx", "is_global"),) + __table_args__ = (Index('is_global_idx', 'is_global'),) class FunctionMeta(BaseModel): description: Optional[str] = None manifest: Optional[dict] = {} - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class FunctionModel(BaseModel): @@ -75,10 +77,6 @@ class FunctionWithValvesModel(BaseModel): #################### -class FunctionUserResponse(FunctionModel): - user: Optional[UserModel] = None - - class FunctionResponse(BaseModel): id: str user_id: str @@ -90,6 +88,12 @@ class FunctionResponse(BaseModel): updated_at: int # timestamp in epoch created_at: int # timestamp in epoch + model_config = ConfigDict(from_attributes=True) + + +class FunctionUserResponse(FunctionResponse): + user: Optional[UserResponse] = None + class FunctionForm(BaseModel): id: str @@ -113,10 +117,10 @@ def insert_new_function( function = FunctionModel( **{ **form_data.model_dump(), - "user_id": user_id, - "type": type, - "updated_at": int(time.time()), - "created_at": int(time.time()), + 'user_id': user_id, + 'type': type, + 'updated_at': int(time.time()), + 'created_at': int(time.time()), } ) @@ -131,7 +135,7 @@ def insert_new_function( else: return None except Exception as e: - log.exception(f"Error creating a new function: {e}") + log.exception(f'Error creating a new function: {e}') return None def sync_functions( @@ -156,16 +160,16 @@ def sync_functions( db.query(Function).filter_by(id=func.id).update( { **func.model_dump(), - "user_id": user_id, - "updated_at": int(time.time()), + 'user_id': user_id, + 'updated_at': int(time.time()), } ) else: new_func = Function( **{ **func.model_dump(), - "user_id": user_id, - "updated_at": int(time.time()), + 'user_id': user_id, + 'updated_at': int(time.time()), } ) db.add(new_func) @@ -177,17 +181,12 @@ def sync_functions( db.commit() - return [ - FunctionModel.model_validate(func) - for func in db.query(Function).all() - ] + return [FunctionModel.model_validate(func) for func in db.query(Function).all()] except Exception as e: - log.exception(f"Error syncing functions for user {user_id}: {e}") + log.exception(f'Error syncing functions for user {user_id}: {e}') return [] - def get_function_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[FunctionModel]: + def get_function_by_id(self, id: str, db: Optional[Session] = None) -> Optional[FunctionModel]: try: with get_db_context(db) as db: function = db.get(Function, id) @@ -195,9 +194,7 @@ def get_function_by_id( except Exception: return None - def get_functions_by_ids( - self, ids: list[str], db: Optional[Session] = None - ) -> list[FunctionModel]: + def get_functions_by_ids(self, ids: list[str], db: Optional[Session] = None) -> list[FunctionModel]: """ Batch fetch multiple functions by their IDs in a single query. Returns functions in the same order as the input IDs (None entries filtered out). @@ -225,20 +222,13 @@ def get_functions( functions = db.query(Function).all() if include_valves: - return [ - FunctionWithValvesModel.model_validate(function) - for function in functions - ] + return [FunctionWithValvesModel.model_validate(function) for function in functions] else: - return [ - FunctionModel.model_validate(function) for function in functions - ] + return [FunctionModel.model_validate(function) for function in functions] - def get_function_list( - self, db: Optional[Session] = None - ) -> list[FunctionUserResponse]: + def get_function_list(self, db: Optional[Session] = None) -> list[FunctionUserResponse]: with get_db_context(db) as db: - functions = db.query(Function).order_by(Function.updated_at.desc()).all() + functions = db.query(Function).options(defer(Function.content)).order_by(Function.updated_at.desc()).all() user_ids = list(set(func.user_id for func in functions)) users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] @@ -247,9 +237,14 @@ def get_function_list( return [ FunctionUserResponse.model_validate( { - **FunctionModel.model_validate(func).model_dump(), - "user": ( - users_dict.get(func.user_id).model_dump() + **FunctionResponse.model_validate(func).model_dump(), + 'user': ( + UserResponse( + id=users_dict[func.user_id].id, + name=users_dict[func.user_id].name, + role=users_dict[func.user_id].role, + email=users_dict[func.user_id].email, + ).model_dump() if func.user_id in users_dict else None ), @@ -258,59 +253,42 @@ def get_function_list( for func in functions ] - def get_functions_by_type( - self, type: str, active_only=False, db: Optional[Session] = None - ) -> list[FunctionModel]: + def get_functions_by_type(self, type: str, active_only=False, db: Optional[Session] = None) -> list[FunctionModel]: with get_db_context(db) as db: if active_only: return [ FunctionModel.model_validate(function) - for function in db.query(Function) - .filter_by(type=type, is_active=True) - .all() + for function in db.query(Function).filter_by(type=type, is_active=True).all() ] else: return [ - FunctionModel.model_validate(function) - for function in db.query(Function).filter_by(type=type).all() + FunctionModel.model_validate(function) for function in db.query(Function).filter_by(type=type).all() ] - def get_global_filter_functions( - self, db: Optional[Session] = None - ) -> list[FunctionModel]: + def get_global_filter_functions(self, db: Optional[Session] = None) -> list[FunctionModel]: with get_db_context(db) as db: return [ FunctionModel.model_validate(function) - for function in db.query(Function) - .filter_by(type="filter", is_active=True, is_global=True) - .all() + for function in db.query(Function).filter_by(type='filter', is_active=True, is_global=True).all() ] - def get_global_action_functions( - self, db: Optional[Session] = None - ) -> list[FunctionModel]: + def get_global_action_functions(self, db: Optional[Session] = None) -> list[FunctionModel]: with get_db_context(db) as db: return [ FunctionModel.model_validate(function) - for function in db.query(Function) - .filter_by(type="action", is_active=True, is_global=True) - .all() + for function in db.query(Function).filter_by(type='action', is_active=True, is_global=True).all() ] - def get_function_valves_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[dict]: + def get_function_valves_by_id(self, id: str, db: Optional[Session] = None) -> Optional[dict]: with get_db_context(db) as db: try: function = db.get(Function, id) return function.valves if function.valves else {} except Exception as e: - log.exception(f"Error getting function valves by id {id}: {e}") + log.exception(f'Error getting function valves by id {id}: {e}') return None - def get_function_valves_by_ids( - self, ids: list[str], db: Optional[Session] = None - ) -> dict[str, dict]: + def get_function_valves_by_ids(self, ids: list[str], db: Optional[Session] = None) -> dict[str, dict]: """ Batch fetch valves for multiple functions in a single query. Returns a dict mapping function_id -> valves dict. @@ -320,14 +298,10 @@ def get_function_valves_by_ids( return {} try: with get_db_context(db) as db: - functions = ( - db.query(Function.id, Function.valves) - .filter(Function.id.in_(ids)) - .all() - ) + functions = db.query(Function.id, Function.valves).filter(Function.id.in_(ids)).all() return {f.id: (f.valves if f.valves else {}) for f in functions} except Exception as e: - log.exception(f"Error batch-fetching function valves: {e}") + log.exception(f'Error batch-fetching function valves: {e}') return {} def update_function_valves_by_id( @@ -364,25 +338,23 @@ def update_function_metadata_by_id( else: return None except Exception as e: - log.exception(f"Error updating function metadata by id {id}: {e}") + log.exception(f'Error updating function metadata by id {id}: {e}') return None - def get_user_valves_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> Optional[dict]: + def get_user_valves_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[dict]: try: user = Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "functions" and "valves" settings - if "functions" not in user_settings: - user_settings["functions"] = {} - if "valves" not in user_settings["functions"]: - user_settings["functions"]["valves"] = {} + if 'functions' not in user_settings: + user_settings['functions'] = {} + if 'valves' not in user_settings['functions']: + user_settings['functions']['valves'] = {} - return user_settings["functions"]["valves"].get(id, {}) + return user_settings['functions']['valves'].get(id, {}) except Exception as e: - log.exception(f"Error getting user values by id {id} and user id {user_id}") + log.exception(f'Error getting user values by id {id} and user id {user_id}') return None def update_user_valves_by_id_and_user_id( @@ -393,32 +365,28 @@ def update_user_valves_by_id_and_user_id( user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "functions" and "valves" settings - if "functions" not in user_settings: - user_settings["functions"] = {} - if "valves" not in user_settings["functions"]: - user_settings["functions"]["valves"] = {} + if 'functions' not in user_settings: + user_settings['functions'] = {} + if 'valves' not in user_settings['functions']: + user_settings['functions']['valves'] = {} - user_settings["functions"]["valves"][id] = valves + user_settings['functions']['valves'][id] = valves # Update the user settings in the database - Users.update_user_by_id(user_id, {"settings": user_settings}, db=db) + Users.update_user_by_id(user_id, {'settings': user_settings}, db=db) - return user_settings["functions"]["valves"][id] + return user_settings['functions']['valves'][id] except Exception as e: - log.exception( - f"Error updating user valves by id {id} and user_id {user_id}: {e}" - ) + log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}') return None - def update_function_by_id( - self, id: str, updated: dict, db: Optional[Session] = None - ) -> Optional[FunctionModel]: + def update_function_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[FunctionModel]: with get_db_context(db) as db: try: db.query(Function).filter_by(id=id).update( { **updated, - "updated_at": int(time.time()), + 'updated_at': int(time.time()), } ) db.commit() @@ -432,8 +400,8 @@ def deactivate_all_functions(self, db: Optional[Session] = None) -> Optional[boo try: db.query(Function).update( { - "is_active": False, - "updated_at": int(time.time()), + 'is_active': False, + 'updated_at': int(time.time()), } ) db.commit() diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index 4c7f456e59..fc4cfb0d31 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -30,11 +30,13 @@ #################### # UserGroup DB Schema +# Let none who belong to this house be turned away, +# and let the covenant hold for every member. #################### class Group(Base): - __tablename__ = "group" + __tablename__ = 'group' id = Column(Text, unique=True, primary_key=True) user_id = Column(Text) @@ -70,12 +72,12 @@ class GroupModel(BaseModel): class GroupMember(Base): - __tablename__ = "group_member" + __tablename__ = 'group_member' id = Column(Text, unique=True, primary_key=True) group_id = Column( Text, - ForeignKey("group.id", ondelete="CASCADE"), + ForeignKey('group.id', ondelete='CASCADE'), nullable=False, ) user_id = Column(Text, nullable=False) @@ -133,28 +135,26 @@ class GroupListResponse(BaseModel): class GroupTable: def _ensure_default_share_config(self, group_data: dict) -> dict: """Ensure the group data dict has a default share config if not already set.""" - if "data" not in group_data or group_data["data"] is None: - group_data["data"] = {} - if "config" not in group_data["data"]: - group_data["data"]["config"] = {} - if "share" not in group_data["data"]["config"]: - group_data["data"]["config"]["share"] = DEFAULT_GROUP_SHARE_PERMISSION + if 'data' not in group_data or group_data['data'] is None: + group_data['data'] = {} + if 'config' not in group_data['data']: + group_data['data']['config'] = {} + if 'share' not in group_data['data']['config']: + group_data['data']['config']['share'] = DEFAULT_GROUP_SHARE_PERMISSION return group_data def insert_new_group( self, user_id: str, form_data: GroupForm, db: Optional[Session] = None ) -> Optional[GroupModel]: with get_db_context(db) as db: - group_data = self._ensure_default_share_config( - form_data.model_dump(exclude_none=True) - ) + group_data = self._ensure_default_share_config(form_data.model_dump(exclude_none=True)) group = GroupModel( **{ **group_data, - "id": str(uuid.uuid4()), - "user_id": user_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) @@ -176,6 +176,11 @@ def get_all_groups(self, db: Optional[Session] = None) -> list[GroupModel]: groups = db.query(Group).order_by(Group.updated_at.desc()).all() return [GroupModel.model_validate(group) for group in groups] + def get_group_by_name(self, name: str, db: Optional[Session] = None) -> Optional[GroupModel]: + with get_db_context(db) as db: + group = db.query(Group).filter(Group.name == name).first() + return GroupModel.model_validate(group) if group else None + def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]: with get_db_context(db) as db: member_count = ( @@ -183,19 +188,19 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse .where(GroupMember.group_id == Group.id) .correlate(Group) .scalar_subquery() - .label("member_count") + .label('member_count') ) query = db.query(Group, member_count) if filter: - if "query" in filter: - query = query.filter(Group.name.ilike(f"%{filter['query']}%")) + if 'query' in filter: + query = query.filter(Group.name.ilike(f'%{filter["query"]}%')) # When share filter is present, member check is handled in the share logic - if "share" in filter: - share_value = filter["share"] - member_id = filter.get("member_id") - json_share = Group.data["config"]["share"] + if 'share' in filter: + share_value = filter['share'] + member_id = filter.get('member_id') + json_share = Group.data['config']['share'] json_share_str = json_share.as_string() json_share_lower = func.lower(json_share_str) @@ -203,37 +208,27 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse anyone_can_share = or_( Group.data.is_(None), json_share_str.is_(None), - json_share_lower == "true", - json_share_lower == "1", # Handle SQLite boolean true + json_share_lower == 'true', + json_share_lower == '1', # Handle SQLite boolean true ) if member_id: - member_groups_select = select(GroupMember.group_id).where( - GroupMember.user_id == member_id - ) + member_groups_select = select(GroupMember.group_id).where(GroupMember.user_id == member_id) members_only_and_is_member = and_( - json_share_lower == "members", + json_share_lower == 'members', Group.id.in_(member_groups_select), ) - query = query.filter( - or_(anyone_can_share, members_only_and_is_member) - ) + query = query.filter(or_(anyone_can_share, members_only_and_is_member)) else: query = query.filter(anyone_can_share) else: - query = query.filter( - and_(Group.data.isnot(None), json_share_lower == "false") - ) + query = query.filter(and_(Group.data.isnot(None), json_share_lower == 'false')) else: # Only apply member_id filter when share filter is NOT present - if "member_id" in filter: + if 'member_id' in filter: query = query.filter( - Group.id.in_( - select(GroupMember.group_id).where( - GroupMember.user_id == filter["member_id"] - ) - ) + Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id'])) ) results = query.order_by(Group.updated_at.desc()).all() @@ -242,7 +237,7 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse GroupResponse.model_validate( { **GroupModel.model_validate(group).model_dump(), - "member_count": count or 0, + 'member_count': count or 0, } ) for group, count in results @@ -259,22 +254,16 @@ def search_groups( query = db.query(Group) if filter: - if "query" in filter: - query = query.filter(Group.name.ilike(f"%{filter['query']}%")) - if "member_id" in filter: + if 'query' in filter: + query = query.filter(Group.name.ilike(f'%{filter["query"]}%')) + if 'member_id' in filter: query = query.filter( - Group.id.in_( - select(GroupMember.group_id).where( - GroupMember.user_id == filter["member_id"] - ) - ) + Group.id.in_(select(GroupMember.group_id).where(GroupMember.user_id == filter['member_id'])) ) - if "share" in filter: - share_value = filter["share"] - query = query.filter( - Group.data.op("->>")("share") == str(share_value) - ) + if 'share' in filter: + share_value = filter['share'] + query = query.filter(Group.data.op('->>')('share') == str(share_value)) total = query.count() @@ -283,32 +272,24 @@ def search_groups( .where(GroupMember.group_id == Group.id) .correlate(Group) .scalar_subquery() - .label("member_count") - ) - results = ( - query.add_columns(member_count) - .order_by(Group.updated_at.desc()) - .offset(skip) - .limit(limit) - .all() + .label('member_count') ) + results = query.add_columns(member_count).order_by(Group.updated_at.desc()).offset(skip).limit(limit).all() return { - "items": [ + 'items': [ GroupResponse.model_validate( { **GroupModel.model_validate(group).model_dump(), - "member_count": count or 0, + 'member_count': count or 0, } ) for group, count in results ], - "total": total, + 'total': total, } - def get_groups_by_member_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[GroupModel]: + def get_groups_by_member_id(self, user_id: str, db: Optional[Session] = None) -> list[GroupModel]: with get_db_context(db) as db: return [ GroupModel.model_validate(group) @@ -340,9 +321,7 @@ def get_groups_by_member_ids( return user_groups - def get_group_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[GroupModel]: + def get_group_by_id(self, id: str, db: Optional[Session] = None) -> Optional[GroupModel]: try: with get_db_context(db) as db: group = db.query(Group).filter_by(id=id).first() @@ -350,41 +329,29 @@ def get_group_by_id( except Exception: return None - def get_group_user_ids_by_id( - self, id: str, db: Optional[Session] = None - ) -> list[str]: + def get_group_user_ids_by_id(self, id: str, db: Optional[Session] = None) -> list[str]: with get_db_context(db) as db: - members = ( - db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all() - ) + members = db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all() if not members: return [] return [m[0] for m in members] - def get_group_user_ids_by_ids( - self, group_ids: list[str], db: Optional[Session] = None - ) -> dict[str, list[str]]: + def get_group_user_ids_by_ids(self, group_ids: list[str], db: Optional[Session] = None) -> dict[str, list[str]]: with get_db_context(db) as db: members = ( - db.query(GroupMember.group_id, GroupMember.user_id) - .filter(GroupMember.group_id.in_(group_ids)) - .all() + db.query(GroupMember.group_id, GroupMember.user_id).filter(GroupMember.group_id.in_(group_ids)).all() ) - group_user_ids: dict[str, list[str]] = { - group_id: [] for group_id in group_ids - } + group_user_ids: dict[str, list[str]] = {group_id: [] for group_id in group_ids} for group_id, user_id in members: group_user_ids[group_id].append(user_id) return group_user_ids - def set_group_user_ids_by_id( - self, group_id: str, user_ids: list[str], db: Optional[Session] = None - ) -> None: + def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str], db: Optional[Session] = None) -> None: with get_db_context(db) as db: # Delete existing members db.query(GroupMember).filter(GroupMember.group_id == group_id).delete() @@ -405,20 +372,12 @@ def set_group_user_ids_by_id( db.add_all(new_members) db.commit() - def get_group_member_count_by_id( - self, id: str, db: Optional[Session] = None - ) -> int: + def get_group_member_count_by_id(self, id: str, db: Optional[Session] = None) -> int: with get_db_context(db) as db: - count = ( - db.query(func.count(GroupMember.user_id)) - .filter(GroupMember.group_id == id) - .scalar() - ) + count = db.query(func.count(GroupMember.user_id)).filter(GroupMember.group_id == id).scalar() return count if count else 0 - def get_group_member_counts_by_ids( - self, ids: list[str], db: Optional[Session] = None - ) -> dict[str, int]: + def get_group_member_counts_by_ids(self, ids: list[str], db: Optional[Session] = None) -> dict[str, int]: if not ids: return {} with get_db_context(db) as db: @@ -442,7 +401,7 @@ def update_group_by_id( db.query(Group).filter_by(id=id).update( { **form_data.model_dump(exclude_none=True), - "updated_at": int(time.time()), + 'updated_at': int(time.time()), } ) db.commit() @@ -470,9 +429,7 @@ def delete_all_groups(self, db: Optional[Session] = None) -> bool: except Exception: return False - def remove_user_from_all_groups( - self, user_id: str, db: Optional[Session] = None - ) -> bool: + def remove_user_from_all_groups(self, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: try: # Find all groups the user belongs to @@ -489,9 +446,7 @@ def remove_user_from_all_groups( GroupMember.group_id == group.id, GroupMember.user_id == user_id ).delete() - db.query(Group).filter_by(id=group.id).update( - {"updated_at": int(time.time())} - ) + db.query(Group).filter_by(id=group.id).update({'updated_at': int(time.time())}) db.commit() return True @@ -503,7 +458,6 @@ def remove_user_from_all_groups( def create_groups_by_group_names( self, user_id: str, group_names: list[str], db: Optional[Session] = None ) -> list[GroupModel]: - # check for existing groups existing_groups = self.get_all_groups(db=db) existing_group_names = {group.name for group in existing_groups} @@ -517,10 +471,10 @@ def create_groups_by_group_names( id=str(uuid.uuid4()), user_id=user_id, name=group_name, - description="", + description='', data={ - "config": { - "share": DEFAULT_GROUP_SHARE_PERMISSION, + 'config': { + 'share': DEFAULT_GROUP_SHARE_PERMISSION, } }, created_at=int(time.time()), @@ -537,17 +491,13 @@ def create_groups_by_group_names( continue return new_groups - def sync_groups_by_group_names( - self, user_id: str, group_names: list[str], db: Optional[Session] = None - ) -> bool: + def sync_groups_by_group_names(self, user_id: str, group_names: list[str], db: Optional[Session] = None) -> bool: with get_db_context(db) as db: try: now = int(time.time()) # 1. Groups that SHOULD contain the user - target_groups = ( - db.query(Group).filter(Group.name.in_(group_names)).all() - ) + target_groups = db.query(Group).filter(Group.name.in_(group_names)).all() target_group_ids = {g.id for g in target_groups} # 2. Groups the user is CURRENTLY in @@ -571,7 +521,7 @@ def sync_groups_by_group_names( ).delete(synchronize_session=False) db.query(Group).filter(Group.id.in_(groups_to_remove)).update( - {"updated_at": now}, synchronize_session=False + {'updated_at': now}, synchronize_session=False ) # 5. Bulk insert missing memberships @@ -588,7 +538,7 @@ def sync_groups_by_group_names( if groups_to_add: db.query(Group).filter(Group.id.in_(groups_to_add)).update( - {"updated_at": now}, synchronize_session=False + {'updated_at': now}, synchronize_session=False ) db.commit() @@ -656,9 +606,9 @@ def remove_users_from_group( return GroupModel.model_validate(group) # Remove users from group_member in batch - db.query(GroupMember).filter( - GroupMember.group_id == id, GroupMember.user_id.in_(user_ids) - ).delete(synchronize_session=False) + db.query(GroupMember).filter(GroupMember.group_id == id, GroupMember.user_id.in_(user_ids)).delete( + synchronize_session=False + ) # Update group timestamp group.updated_at = int(time.time()) diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 4212cf0fd5..30510221fb 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -34,11 +34,13 @@ #################### # Knowledge DB Schema +# Let what was gathered here outlast the one who gathered it, +# and still teach when the builder is gone. #################### class Knowledge(Base): - __tablename__ = "knowledge" + __tablename__ = 'knowledge' id = Column(Text, unique=True, primary_key=True) user_id = Column(Text) @@ -70,24 +72,18 @@ class KnowledgeModel(BaseModel): class KnowledgeFile(Base): - __tablename__ = "knowledge_file" + __tablename__ = 'knowledge_file' id = Column(Text, unique=True, primary_key=True) - knowledge_id = Column( - Text, ForeignKey("knowledge.id", ondelete="CASCADE"), nullable=False - ) - file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False) + knowledge_id = Column(Text, ForeignKey('knowledge.id', ondelete='CASCADE'), nullable=False) + file_id = Column(Text, ForeignKey('file.id', ondelete='CASCADE'), nullable=False) user_id = Column(Text, nullable=False) created_at = Column(BigInteger, nullable=False) updated_at = Column(BigInteger, nullable=False) - __table_args__ = ( - UniqueConstraint( - "knowledge_id", "file_id", name="uq_knowledge_file_knowledge_file" - ), - ) + __table_args__ = (UniqueConstraint('knowledge_id', 'file_id', name='uq_knowledge_file_knowledge_file'),) class KnowledgeFileModel(BaseModel): @@ -138,10 +134,8 @@ class KnowledgeFileListResponse(BaseModel): class KnowledgeTable: - def _get_access_grants( - self, knowledge_id: str, db: Optional[Session] = None - ) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource("knowledge", knowledge_id, db=db) + def _get_access_grants(self, knowledge_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource('knowledge', knowledge_id, db=db) def _to_knowledge_model( self, @@ -149,13 +143,9 @@ def _to_knowledge_model( access_grants: Optional[list[AccessGrantModel]] = None, db: Optional[Session] = None, ) -> KnowledgeModel: - knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump( - exclude={"access_grants"} - ) - knowledge_data["access_grants"] = ( - access_grants - if access_grants is not None - else self._get_access_grants(knowledge_data["id"], db=db) + knowledge_data = KnowledgeModel.model_validate(knowledge).model_dump(exclude={'access_grants'}) + knowledge_data['access_grants'] = ( + access_grants if access_grants is not None else self._get_access_grants(knowledge_data['id'], db=db) ) return KnowledgeModel.model_validate(knowledge_data) @@ -165,23 +155,21 @@ def insert_new_knowledge( with get_db_context(db) as db: knowledge = KnowledgeModel( **{ - **form_data.model_dump(exclude={"access_grants"}), - "id": str(uuid.uuid4()), - "user_id": user_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), - "access_grants": [], + **form_data.model_dump(exclude={'access_grants'}), + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), + 'access_grants': [], } ) try: - result = Knowledge(**knowledge.model_dump(exclude={"access_grants"})) + result = Knowledge(**knowledge.model_dump(exclude={'access_grants'})) db.add(result) db.commit() db.refresh(result) - AccessGrants.set_access_grants( - "knowledge", result.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('knowledge', result.id, form_data.access_grants, db=db) if result: return self._to_knowledge_model(result, db=db) else: @@ -193,17 +181,13 @@ def get_knowledge_bases( self, skip: int = 0, limit: int = 30, db: Optional[Session] = None ) -> list[KnowledgeUserModel]: with get_db_context(db) as db: - all_knowledge = ( - db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() - ) + all_knowledge = db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all() user_ids = list(set(knowledge.user_id for knowledge in all_knowledge)) knowledge_ids = [knowledge.id for knowledge in all_knowledge] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources( - "knowledge", knowledge_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) knowledge_bases = [] for knowledge in all_knowledge: @@ -216,7 +200,7 @@ def get_knowledge_bases( access_grants=grants_map.get(knowledge.id, []), db=db, ).model_dump(), - "user": user.model_dump() if user else None, + 'user': user.model_dump() if user else None, } ) ) @@ -232,27 +216,25 @@ def search_knowledge_bases( ) -> KnowledgeListResponse: try: with get_db_context(db) as db: - query = db.query(Knowledge, User).outerjoin( - User, User.id == Knowledge.user_id - ) + query = db.query(Knowledge, User).outerjoin(User, User.id == Knowledge.user_id) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: query = query.filter( or_( - Knowledge.name.ilike(f"%{query_key}%"), - Knowledge.description.ilike(f"%{query_key}%"), - User.name.ilike(f"%{query_key}%"), - User.email.ilike(f"%{query_key}%"), - User.username.ilike(f"%{query_key}%"), + Knowledge.name.ilike(f'%{query_key}%'), + Knowledge.description.ilike(f'%{query_key}%'), + User.name.ilike(f'%{query_key}%'), + User.email.ilike(f'%{query_key}%'), + User.username.ilike(f'%{query_key}%'), ) ) - view_option = filter.get("view_option") - if view_option == "created": + view_option = filter.get('view_option') + if view_option == 'created': query = query.filter(Knowledge.user_id == user_id) - elif view_option == "shared": + elif view_option == 'shared': query = query.filter(Knowledge.user_id != user_id) query = AccessGrants.has_permission_filter( @@ -260,8 +242,8 @@ def search_knowledge_bases( query=query, DocumentModel=Knowledge, filter=filter, - resource_type="knowledge", - permission="read", + resource_type='knowledge', + permission='read', ) query = query.order_by(Knowledge.updated_at.desc(), Knowledge.id.asc()) @@ -275,9 +257,7 @@ def search_knowledge_bases( items = query.all() knowledge_ids = [kb.id for kb, _ in items] - grants_map = AccessGrants.get_grants_by_resources( - "knowledge", knowledge_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) knowledge_bases = [] for knowledge_base, user in items: @@ -289,11 +269,7 @@ def search_knowledge_bases( access_grants=grants_map.get(knowledge_base.id, []), db=db, ).model_dump(), - "user": ( - UserModel.model_validate(user).model_dump() - if user - else None - ), + 'user': (UserModel.model_validate(user).model_dump() if user else None), } ) ) @@ -327,15 +303,15 @@ def search_knowledge_files( query=query, DocumentModel=Knowledge, filter=filter, - resource_type="knowledge", - permission="read", + resource_type='knowledge', + permission='read', ) # Apply filename search if filter: - q = filter.get("query") + q = filter.get('query') if q: - query = query.filter(File.filename.ilike(f"%{q}%")) + query = query.filter(File.filename.ilike(f'%{q}%')) # Order by file changes query = query.order_by(File.updated_at.desc(), File.id.asc()) @@ -355,39 +331,27 @@ def search_knowledge_files( items.append( FileUserResponse( **FileModel.model_validate(file).model_dump(), - user=( - UserResponse( - **UserModel.model_validate(user).model_dump() - ) - if user - else None - ), - collection=self._to_knowledge_model( - knowledge, db=db - ).model_dump(), + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), + collection=self._to_knowledge_model(knowledge, db=db).model_dump(), ) ) return KnowledgeFileListResponse(items=items, total=total) except Exception as e: - print("search_knowledge_files error:", e) + print('search_knowledge_files error:', e) return KnowledgeFileListResponse(items=[], total=0) - def check_access_by_user_id( - self, id, user_id, permission="write", db: Optional[Session] = None - ) -> bool: + def check_access_by_user_id(self, id, user_id, permission='write', db: Optional[Session] = None) -> bool: knowledge = self.get_knowledge_by_id(id, db=db) if not knowledge: return False if knowledge.user_id == user_id: return True - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} return AccessGrants.has_access( user_id=user_id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, permission=permission, user_group_ids=user_group_ids, @@ -395,19 +359,17 @@ def check_access_by_user_id( ) def get_knowledge_bases_by_user_id( - self, user_id: str, permission: str = "write", db: Optional[Session] = None + self, user_id: str, permission: str = 'write', db: Optional[Session] = None ) -> list[KnowledgeUserModel]: knowledge_bases = self.get_knowledge_bases(db=db) - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} return [ knowledge_base for knowledge_base in knowledge_bases if knowledge_base.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge_base.id, permission=permission, user_group_ids=user_group_ids, @@ -415,9 +377,7 @@ def get_knowledge_bases_by_user_id( ) ] - def get_knowledge_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[KnowledgeModel]: + def get_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> Optional[KnowledgeModel]: try: with get_db_context(db) as db: knowledge = db.query(Knowledge).filter_by(id=id).first() @@ -435,23 +395,19 @@ def get_knowledge_by_id_and_user_id( if knowledge.user_id == user_id: return knowledge - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} if AccessGrants.has_access( user_id=user_id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', user_group_ids=user_group_ids, db=db, ): return knowledge return None - def get_knowledges_by_file_id( - self, file_id: str, db: Optional[Session] = None - ) -> list[KnowledgeModel]: + def get_knowledges_by_file_id(self, file_id: str, db: Optional[Session] = None) -> list[KnowledgeModel]: try: with get_db_context(db) as db: knowledges = ( @@ -461,9 +417,7 @@ def get_knowledges_by_file_id( .all() ) knowledge_ids = [k.id for k in knowledges] - grants_map = AccessGrants.get_grants_by_resources( - "knowledge", knowledge_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db) return [ self._to_knowledge_model( knowledge, @@ -497,32 +451,26 @@ def search_files_by_id( primary_sort = File.updated_at.desc() if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: - query = query.filter(or_(File.filename.ilike(f"%{query_key}%"))) + query = query.filter(or_(File.filename.ilike(f'%{query_key}%'))) - view_option = filter.get("view_option") - if view_option == "created": + view_option = filter.get('view_option') + if view_option == 'created': query = query.filter(KnowledgeFile.user_id == user_id) - elif view_option == "shared": + elif view_option == 'shared': query = query.filter(KnowledgeFile.user_id != user_id) - order_by = filter.get("order_by") - direction = filter.get("direction") - is_asc = direction == "asc" + order_by = filter.get('order_by') + direction = filter.get('direction') + is_asc = direction == 'asc' - if order_by == "name": - primary_sort = ( - File.filename.asc() if is_asc else File.filename.desc() - ) - elif order_by == "created_at": - primary_sort = ( - File.created_at.asc() if is_asc else File.created_at.desc() - ) - elif order_by == "updated_at": - primary_sort = ( - File.updated_at.asc() if is_asc else File.updated_at.desc() - ) + if order_by == 'name': + primary_sort = File.filename.asc() if is_asc else File.filename.desc() + elif order_by == 'created_at': + primary_sort = File.created_at.asc() if is_asc else File.created_at.desc() + elif order_by == 'updated_at': + primary_sort = File.updated_at.asc() if is_asc else File.updated_at.desc() # Apply sort with secondary key for deterministic pagination query = query.order_by(primary_sort, File.id.asc()) @@ -542,13 +490,7 @@ def search_files_by_id( files.append( FileUserResponse( **FileModel.model_validate(file).model_dump(), - user=( - UserResponse( - **UserModel.model_validate(user).model_dump() - ) - if user - else None - ), + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) ) @@ -557,9 +499,7 @@ def search_files_by_id( print(e) return KnowledgeFileListResponse(items=[], total=0) - def get_files_by_id( - self, knowledge_id: str, db: Optional[Session] = None - ) -> list[FileModel]: + def get_files_by_id(self, knowledge_id: str, db: Optional[Session] = None) -> list[FileModel]: try: with get_db_context(db) as db: files = ( @@ -572,9 +512,7 @@ def get_files_by_id( except Exception: return [] - def get_file_metadatas_by_id( - self, knowledge_id: str, db: Optional[Session] = None - ) -> list[FileMetadataResponse]: + def get_file_metadatas_by_id(self, knowledge_id: str, db: Optional[Session] = None) -> list[FileMetadataResponse]: try: with get_db_context(db) as db: files = self.get_files_by_id(knowledge_id, db=db) @@ -592,12 +530,12 @@ def add_file_to_knowledge_by_id( with get_db_context(db) as db: knowledge_file = KnowledgeFileModel( **{ - "id": str(uuid.uuid4()), - "knowledge_id": knowledge_id, - "file_id": file_id, - "user_id": user_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), + 'id': str(uuid.uuid4()), + 'knowledge_id': knowledge_id, + 'file_id': file_id, + 'user_id': user_id, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) @@ -613,37 +551,24 @@ def add_file_to_knowledge_by_id( except Exception: return None - def has_file( - self, knowledge_id: str, file_id: str, db: Optional[Session] = None - ) -> bool: + def has_file(self, knowledge_id: str, file_id: str, db: Optional[Session] = None) -> bool: """Check whether a file belongs to a knowledge base.""" try: with get_db_context(db) as db: - return ( - db.query(KnowledgeFile) - .filter_by(knowledge_id=knowledge_id, file_id=file_id) - .first() - is not None - ) + return db.query(KnowledgeFile).filter_by(knowledge_id=knowledge_id, file_id=file_id).first() is not None except Exception: return False - def remove_file_from_knowledge_by_id( - self, knowledge_id: str, file_id: str, db: Optional[Session] = None - ) -> bool: + def remove_file_from_knowledge_by_id(self, knowledge_id: str, file_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - db.query(KnowledgeFile).filter_by( - knowledge_id=knowledge_id, file_id=file_id - ).delete() + db.query(KnowledgeFile).filter_by(knowledge_id=knowledge_id, file_id=file_id).delete() db.commit() return True except Exception: return False - def reset_knowledge_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[KnowledgeModel]: + def reset_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> Optional[KnowledgeModel]: try: with get_db_context(db) as db: # Delete all knowledge_file entries for this knowledge_id @@ -653,7 +578,7 @@ def reset_knowledge_by_id( # Update the knowledge entry's updated_at timestamp db.query(Knowledge).filter_by(id=id).update( { - "updated_at": int(time.time()), + 'updated_at': int(time.time()), } ) db.commit() @@ -675,15 +600,13 @@ def update_knowledge_by_id( knowledge = self.get_knowledge_by_id(id=id, db=db) db.query(Knowledge).filter_by(id=id).update( { - **form_data.model_dump(exclude={"access_grants"}), - "updated_at": int(time.time()), + **form_data.model_dump(exclude={'access_grants'}), + 'updated_at': int(time.time()), } ) db.commit() if form_data.access_grants is not None: - AccessGrants.set_access_grants( - "knowledge", id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) return self.get_knowledge_by_id(id=id, db=db) except Exception as e: log.exception(e) @@ -697,8 +620,8 @@ def update_knowledge_data_by_id( knowledge = self.get_knowledge_by_id(id=id, db=db) db.query(Knowledge).filter_by(id=id).update( { - "data": data, - "updated_at": int(time.time()), + 'data': data, + 'updated_at': int(time.time()), } ) db.commit() @@ -710,7 +633,7 @@ def update_knowledge_data_by_id( def delete_knowledge_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - AccessGrants.revoke_all_access("knowledge", id, db=db) + AccessGrants.revoke_all_access('knowledge', id, db=db) db.query(Knowledge).filter_by(id=id).delete() db.commit() return True @@ -722,7 +645,7 @@ def delete_all_knowledge(self, db: Optional[Session] = None) -> bool: try: knowledge_ids = [row[0] for row in db.query(Knowledge.id).all()] for knowledge_id in knowledge_ids: - AccessGrants.revoke_all_access("knowledge", knowledge_id, db=db) + AccessGrants.revoke_all_access('knowledge', knowledge_id, db=db) db.query(Knowledge).delete() db.commit() diff --git a/backend/open_webui/models/memories.py b/backend/open_webui/models/memories.py index e6b70a3020..7c34de9f07 100644 --- a/backend/open_webui/models/memories.py +++ b/backend/open_webui/models/memories.py @@ -9,11 +9,13 @@ #################### # Memory DB Schema +# What was learned at cost should not need to be paid +# for again. Let the memory hold. #################### class Memory(Base): - __tablename__ = "memory" + __tablename__ = 'memory' id = Column(String, primary_key=True, unique=True) user_id = Column(String) @@ -49,11 +51,11 @@ def insert_new_memory( memory = MemoryModel( **{ - "id": id, - "user_id": user_id, - "content": content, - "created_at": int(time.time()), - "updated_at": int(time.time()), + 'id': id, + 'user_id': user_id, + 'content': content, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) result = Memory(**memory.model_dump()) @@ -95,9 +97,7 @@ def get_memories(self, db: Optional[Session] = None) -> list[MemoryModel]: except Exception: return None - def get_memories_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[MemoryModel]: + def get_memories_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[MemoryModel]: with get_db_context(db) as db: try: memories = db.query(Memory).filter_by(user_id=user_id).all() @@ -105,9 +105,7 @@ def get_memories_by_user_id( except Exception: return None - def get_memory_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[MemoryModel]: + def get_memory_by_id(self, id: str, db: Optional[Session] = None) -> Optional[MemoryModel]: with get_db_context(db) as db: try: memory = db.get(Memory, id) @@ -126,9 +124,7 @@ def delete_memory_by_id(self, id: str, db: Optional[Session] = None) -> bool: except Exception: return False - def delete_memories_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_memories_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: try: db.query(Memory).filter_by(user_id=user_id).delete() @@ -138,9 +134,7 @@ def delete_memories_by_user_id( except Exception: return False - def delete_memory_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_memory_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: try: memory = db.get(Memory, id) diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 0851107b0b..034eaac160 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -21,7 +21,7 @@ class MessageReaction(Base): - __tablename__ = "message_reaction" + __tablename__ = 'message_reaction' id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) message_id = Column(Text) @@ -40,7 +40,7 @@ class MessageReactionModel(BaseModel): class Message(Base): - __tablename__ = "message" + __tablename__ = 'message' id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) @@ -112,7 +112,7 @@ class MessageUserResponse(MessageModel): class MessageUserSlimResponse(MessageUserResponse): data: bool | None = None - @field_validator("data", mode="before") + @field_validator('data', mode='before') def convert_data_to_bool(cls, v): # No data or not a dict โ†’ False if not isinstance(v, dict): @@ -152,19 +152,19 @@ def insert_new_message( message = MessageModel( **{ - "id": id, - "user_id": user_id, - "channel_id": channel_id, - "reply_to_id": form_data.reply_to_id, - "parent_id": form_data.parent_id, - "is_pinned": False, - "pinned_at": None, - "pinned_by": None, - "content": form_data.content, - "data": form_data.data, - "meta": form_data.meta, - "created_at": ts, - "updated_at": ts, + 'id': id, + 'user_id': user_id, + 'channel_id': channel_id, + 'reply_to_id': form_data.reply_to_id, + 'parent_id': form_data.parent_id, + 'is_pinned': False, + 'pinned_at': None, + 'pinned_by': None, + 'content': form_data.content, + 'data': form_data.data, + 'meta': form_data.meta, + 'created_at': ts, + 'updated_at': ts, } ) result = Message(**message.model_dump()) @@ -186,9 +186,7 @@ def get_message_by_id( return None reply_to_message = ( - self.get_message_by_id( - message.reply_to_id, include_thread_replies=False, db=db - ) + self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) if message.reply_to_id else None ) @@ -200,22 +198,22 @@ def get_message_by_id( thread_replies = self.get_thread_replies_by_message_id(id, db=db) # Check if message was sent by webhook (webhook info in meta takes precedence) - webhook_info = message.meta.get("webhook") if message.meta else None - if webhook_info and webhook_info.get("id"): + webhook_info = message.meta.get('webhook') if message.meta else None + if webhook_info and webhook_info.get('id'): # Look up webhook by ID to get current name - webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db) + webhook = Channels.get_webhook_by_id(webhook_info.get('id'), db=db) if webhook: user_info = { - "id": webhook.id, - "name": webhook.name, - "role": "webhook", + 'id': webhook.id, + 'name': webhook.name, + 'role': 'webhook', } else: # Webhook was deleted, use placeholder user_info = { - "id": webhook_info.get("id"), - "name": "Deleted Webhook", - "role": "webhook", + 'id': webhook_info.get('id'), + 'name': 'Deleted Webhook', + 'role': 'webhook', } else: user = Users.get_user_by_id(message.user_id, db=db) @@ -224,79 +222,57 @@ def get_message_by_id( return MessageResponse.model_validate( { **MessageModel.model_validate(message).model_dump(), - "user": user_info, - "reply_to_message": ( - reply_to_message.model_dump() if reply_to_message else None - ), - "latest_reply_at": ( - thread_replies[0].created_at if thread_replies else None - ), - "reply_count": len(thread_replies), - "reactions": reactions, + 'user': user_info, + 'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None), + 'latest_reply_at': (thread_replies[0].created_at if thread_replies else None), + 'reply_count': len(thread_replies), + 'reactions': reactions, } ) - def get_thread_replies_by_message_id( - self, id: str, db: Optional[Session] = None - ) -> list[MessageReplyToResponse]: + def get_thread_replies_by_message_id(self, id: str, db: Optional[Session] = None) -> list[MessageReplyToResponse]: with get_db_context(db) as db: - all_messages = ( - db.query(Message) - .filter_by(parent_id=id) - .order_by(Message.created_at.desc()) - .all() - ) + all_messages = db.query(Message).filter_by(parent_id=id).order_by(Message.created_at.desc()).all() messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id( - message.reply_to_id, include_thread_replies=False, db=db - ) + self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) if message.reply_to_id else None ) - webhook_info = message.meta.get("webhook") if message.meta else None + webhook_info = message.meta.get('webhook') if message.meta else None user_info = None - if webhook_info and webhook_info.get("id"): - webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db) + if webhook_info and webhook_info.get('id'): + webhook = Channels.get_webhook_by_id(webhook_info.get('id'), db=db) if webhook: user_info = { - "id": webhook.id, - "name": webhook.name, - "role": "webhook", + 'id': webhook.id, + 'name': webhook.name, + 'role': 'webhook', } else: user_info = { - "id": webhook_info.get("id"), - "name": "Deleted Webhook", - "role": "webhook", + 'id': webhook_info.get('id'), + 'name': 'Deleted Webhook', + 'role': 'webhook', } messages.append( MessageReplyToResponse.model_validate( { **MessageModel.model_validate(message).model_dump(), - "user": user_info, - "reply_to_message": ( - reply_to_message.model_dump() - if reply_to_message - else None - ), + 'user': user_info, + 'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None), } ) ) return messages - def get_reply_user_ids_by_message_id( - self, id: str, db: Optional[Session] = None - ) -> list[str]: + def get_reply_user_ids_by_message_id(self, id: str, db: Optional[Session] = None) -> list[str]: with get_db_context(db) as db: - return [ - message.user_id - for message in db.query(Message).filter_by(parent_id=id).all() - ] + return [message.user_id for message in db.query(Message).filter_by(parent_id=id).all()] def get_messages_by_channel_id( self, @@ -318,40 +294,34 @@ def get_messages_by_channel_id( messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id( - message.reply_to_id, include_thread_replies=False, db=db - ) + self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) if message.reply_to_id else None ) - webhook_info = message.meta.get("webhook") if message.meta else None + webhook_info = message.meta.get('webhook') if message.meta else None user_info = None - if webhook_info and webhook_info.get("id"): - webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db) + if webhook_info and webhook_info.get('id'): + webhook = Channels.get_webhook_by_id(webhook_info.get('id'), db=db) if webhook: user_info = { - "id": webhook.id, - "name": webhook.name, - "role": "webhook", + 'id': webhook.id, + 'name': webhook.name, + 'role': 'webhook', } else: user_info = { - "id": webhook_info.get("id"), - "name": "Deleted Webhook", - "role": "webhook", + 'id': webhook_info.get('id'), + 'name': 'Deleted Webhook', + 'role': 'webhook', } messages.append( MessageReplyToResponse.model_validate( { **MessageModel.model_validate(message).model_dump(), - "user": user_info, - "reply_to_message": ( - reply_to_message.model_dump() - if reply_to_message - else None - ), + 'user': user_info, + 'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None), } ) ) @@ -387,55 +357,42 @@ def get_messages_by_parent_id( messages = [] for message in all_messages: reply_to_message = ( - self.get_message_by_id( - message.reply_to_id, include_thread_replies=False, db=db - ) + self.get_message_by_id(message.reply_to_id, include_thread_replies=False, db=db) if message.reply_to_id else None ) - webhook_info = message.meta.get("webhook") if message.meta else None + webhook_info = message.meta.get('webhook') if message.meta else None user_info = None - if webhook_info and webhook_info.get("id"): - webhook = Channels.get_webhook_by_id(webhook_info.get("id"), db=db) + if webhook_info and webhook_info.get('id'): + webhook = Channels.get_webhook_by_id(webhook_info.get('id'), db=db) if webhook: user_info = { - "id": webhook.id, - "name": webhook.name, - "role": "webhook", + 'id': webhook.id, + 'name': webhook.name, + 'role': 'webhook', } else: user_info = { - "id": webhook_info.get("id"), - "name": "Deleted Webhook", - "role": "webhook", + 'id': webhook_info.get('id'), + 'name': 'Deleted Webhook', + 'role': 'webhook', } messages.append( MessageReplyToResponse.model_validate( { **MessageModel.model_validate(message).model_dump(), - "user": user_info, - "reply_to_message": ( - reply_to_message.model_dump() - if reply_to_message - else None - ), + 'user': user_info, + 'reply_to_message': (reply_to_message.model_dump() if reply_to_message else None), } ) ) return messages - def get_last_message_by_channel_id( - self, channel_id: str, db: Optional[Session] = None - ) -> Optional[MessageModel]: + def get_last_message_by_channel_id(self, channel_id: str, db: Optional[Session] = None) -> Optional[MessageModel]: with get_db_context(db) as db: - message = ( - db.query(Message) - .filter_by(channel_id=channel_id) - .order_by(Message.created_at.desc()) - .first() - ) + message = db.query(Message).filter_by(channel_id=channel_id).order_by(Message.created_at.desc()).first() return MessageModel.model_validate(message) if message else None def get_pinned_messages_by_channel_id( @@ -513,11 +470,7 @@ def add_reaction_to_message( ) -> Optional[MessageReactionModel]: with get_db_context(db) as db: # check for existing reaction - existing_reaction = ( - db.query(MessageReaction) - .filter_by(message_id=id, user_id=user_id, name=name) - .first() - ) + existing_reaction = db.query(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name).first() if existing_reaction: return MessageReactionModel.model_validate(existing_reaction) @@ -535,9 +488,7 @@ def add_reaction_to_message( db.refresh(result) return MessageReactionModel.model_validate(result) if result else None - def get_reactions_by_message_id( - self, id: str, db: Optional[Session] = None - ) -> list[Reactions]: + def get_reactions_by_message_id(self, id: str, db: Optional[Session] = None) -> list[Reactions]: with get_db_context(db) as db: # JOIN User so all user info is fetched in one query results = ( @@ -552,18 +503,18 @@ def get_reactions_by_message_id( for reaction, user in results: if reaction.name not in reactions: reactions[reaction.name] = { - "name": reaction.name, - "users": [], - "count": 0, + 'name': reaction.name, + 'users': [], + 'count': 0, } - reactions[reaction.name]["users"].append( + reactions[reaction.name]['users'].append( { - "id": user.id, - "name": user.name, + 'id': user.id, + 'name': user.name, } ) - reactions[reaction.name]["count"] += 1 + reactions[reaction.name]['count'] += 1 return [Reactions(**reaction) for reaction in reactions.values()] @@ -571,9 +522,7 @@ def remove_reaction_by_id_and_user_id_and_name( self, id: str, user_id: str, name: str, db: Optional[Session] = None ) -> bool: with get_db_context(db) as db: - db.query(MessageReaction).filter_by( - message_id=id, user_id=user_id, name=name - ).delete() + db.query(MessageReaction).filter_by(message_id=id, user_id=user_id, name=name).delete() db.commit() return True @@ -612,21 +561,15 @@ def search_messages_by_channel_ids( with get_db_context(db) as db: query_builder = db.query(Message).filter( Message.channel_id.in_(channel_ids), - Message.content.ilike(f"%{query}%"), + Message.content.ilike(f'%{query}%'), ) if start_timestamp: - query_builder = query_builder.filter( - Message.created_at >= start_timestamp - ) + query_builder = query_builder.filter(Message.created_at >= start_timestamp) if end_timestamp: - query_builder = query_builder.filter( - Message.created_at <= end_timestamp - ) + query_builder = query_builder.filter(Message.created_at <= end_timestamp) - messages = ( - query_builder.order_by(Message.created_at.desc()).limit(limit).all() - ) + messages = query_builder.order_by(Message.created_at.desc()).limit(limit).all() return [MessageModel.model_validate(msg) for msg in messages] diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 166743345e..ca2a43a4da 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -10,7 +10,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from sqlalchemy import String, cast, or_, and_, func from sqlalchemy.dialects import postgresql, sqlite @@ -23,18 +23,20 @@ #################### # Models DB Schema +# A misconfigured model wastes the time of everyone +# who trusts it. Let what is set here be set with care. #################### # ModelParams is a model for the data stored in the params field of the Model table class ModelParams(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') pass # ModelMeta is a model for the data stored in the meta field of the Model table class ModelMeta(BaseModel): - profile_image_url: Optional[str] = "/static/favicon.png" + profile_image_url: Optional[str] = '/static/favicon.png' description: Optional[str] = None """ @@ -43,13 +45,26 @@ class ModelMeta(BaseModel): capabilities: Optional[dict] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') - pass + @model_validator(mode='before') + @classmethod + def normalize_tags(cls, data): + if isinstance(data, dict) and 'tags' in data: + raw_tags = data['tags'] + if isinstance(raw_tags, list): + normalized = [] + for tag in raw_tags: + if isinstance(tag, str): + normalized.append({'name': tag}) + elif isinstance(tag, dict) and 'name' in tag: + normalized.append(tag) + data['tags'] = normalized + return data class Model(Base): - __tablename__ = "model" + __tablename__ = 'model' id = Column(Text, primary_key=True, unique=True) """ @@ -133,36 +148,24 @@ class ModelAccessListResponse(BaseModel): class ModelPriceForm(BaseModel): - prompt_price: float = Field( - default=0, description="prompt token price for 1m tokens", ge=0 - ) - completion_price: float = Field( - default=0, description="completion token price for 1m tokens", ge=0 - ) - prompt_long_ctx_tokens: int = Field( - default=200000, description="number of long context tokens for prompt", ge=0 - ) - prompt_long_ctx_price: float = Field( - default=0, description="prompt long context token price for 1m tokens", ge=0 - ) + prompt_price: float = Field(default=0, description='prompt token price for 1m tokens', ge=0) + completion_price: float = Field(default=0, description='completion token price for 1m tokens', ge=0) + prompt_long_ctx_tokens: int = Field(default=200000, description='number of long context tokens for prompt', ge=0) + prompt_long_ctx_price: float = Field(default=0, description='prompt long context token price for 1m tokens', ge=0) completion_long_ctx_tokens: int = Field( - default=200000, description="number of long context tokens for completion", ge=0 + default=200000, description='number of long context tokens for completion', ge=0 ) completion_long_ctx_price: float = Field( - default=0, description="completion long context token price for 1m tokens", ge=0 - ) - prompt_cache_price: float = Field( - default=0, description="prompt cache token price for 1m tokens", ge=0 + default=0, description='completion long context token price for 1m tokens', ge=0 ) + prompt_cache_price: float = Field(default=0, description='prompt cache token price for 1m tokens', ge=0) prompt_long_ctx_cache_price: float = Field( default=0, - description="prompt long context cache token price for 1m tokens", + description='prompt long context cache token price for 1m tokens', ge=0, ) - request_price: float = Field(default=0, description="price for 1m request", ge=0) - minimum_credit: float = Field( - default=0, description="min credit required for this model", ge=0 - ) + request_price: float = Field(default=0, description='price for 1m request', ge=0) + minimum_credit: float = Field(default=0, description='min credit required for this model', ge=0) class ModelForm(BaseModel): @@ -177,10 +180,8 @@ class ModelForm(BaseModel): class ModelsTable: - def _get_access_grants( - self, model_id: str, db: Optional[Session] = None - ) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource("model", model_id, db=db) + def _get_access_grants(self, model_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource('model', model_id, db=db) def _to_model_model( self, @@ -188,13 +189,9 @@ def _to_model_model( access_grants: Optional[list[AccessGrantModel]] = None, db: Optional[Session] = None, ) -> ModelModel: - model_data = ModelModel.model_validate(model).model_dump( - exclude={"access_grants"} - ) - model_data["access_grants"] = ( - access_grants - if access_grants is not None - else self._get_access_grants(model_data["id"], db=db) + model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'}) + model_data['access_grants'] = ( + access_grants if access_grants is not None else self._get_access_grants(model_data['id'], db=db) ) return ModelModel.model_validate(model_data) @@ -205,37 +202,32 @@ def insert_new_model( with get_db_context(db) as db: result = Model( **{ - **form_data.model_dump(exclude={"access_grants"}), - "user_id": user_id, - "created_at": int(time.time()), - "updated_at": int(time.time()), + **form_data.model_dump(exclude={'access_grants'}), + 'user_id': user_id, + 'created_at': int(time.time()), + 'updated_at': int(time.time()), } ) db.add(result) db.commit() db.refresh(result) - AccessGrants.set_access_grants( - "model", result.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('model', result.id, form_data.access_grants, db=db) if result: return self._to_model_model(result, db=db) else: return None except Exception as e: - log.exception(f"Failed to insert a new model: {e}") + log.exception(f'Failed to insert a new model: {e}') return None def get_all_models(self, db: Optional[Session] = None) -> list[ModelModel]: with get_db_context(db) as db: all_models = db.query(Model).all() model_ids = [model.id for model in all_models] - grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db) + grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) return [ - self._to_model_model( - model, access_grants=grants_map.get(model.id, []), db=db - ) - for model in all_models + self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db) for model in all_models ] def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: @@ -247,7 +239,7 @@ def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db) + grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) models = [] for model in all_models: @@ -260,7 +252,7 @@ def get_models(self, db: Optional[Session] = None) -> list[ModelUserResponse]: access_grants=grants_map.get(model.id, []), db=db, ).model_dump(), - "user": user.model_dump() if user else None, + 'user': user.model_dump() if user else None, } ) ) @@ -270,28 +262,23 @@ def get_base_models(self, db: Optional[Session] = None) -> list[ModelModel]: with get_db_context(db) as db: all_models = db.query(Model).filter(Model.base_model_id == None).all() model_ids = [model.id for model in all_models] - grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db) + grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) return [ - self._to_model_model( - model, access_grants=grants_map.get(model.id, []), db=db - ) - for model in all_models + self._to_model_model(model, access_grants=grants_map.get(model.id, []), db=db) for model in all_models ] def get_models_by_user_id( - self, user_id: str, permission: str = "write", db: Optional[Session] = None + self, user_id: str, permission: str = 'write', db: Optional[Session] = None ) -> list[ModelUserResponse]: models = self.get_models(db=db) - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} return [ model for model in models if model.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="model", + resource_type='model', resource_id=model.id, permission=permission, user_group_ids=user_group_ids, @@ -299,13 +286,13 @@ def get_models_by_user_id( ) ] - def _has_permission(self, db, query, filter: dict, permission: str = "read"): + def _has_permission(self, db, query, filter: dict, permission: str = 'read'): return AccessGrants.has_permission_filter( db=db, query=query, DocumentModel=Model, filter=filter, - resource_type="model", + resource_type='model', permission=permission, ) @@ -323,22 +310,22 @@ def search_models( query = query.filter(Model.base_model_id != None) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: query = query.filter( or_( - Model.name.ilike(f"%{query_key}%"), - Model.base_model_id.ilike(f"%{query_key}%"), - User.name.ilike(f"%{query_key}%"), - User.email.ilike(f"%{query_key}%"), - User.username.ilike(f"%{query_key}%"), + Model.name.ilike(f'%{query_key}%'), + Model.base_model_id.ilike(f'%{query_key}%'), + User.name.ilike(f'%{query_key}%'), + User.email.ilike(f'%{query_key}%'), + User.username.ilike(f'%{query_key}%'), ) ) - view_option = filter.get("view_option") - if view_option == "created": + view_option = filter.get('view_option') + if view_option == 'created': query = query.filter(Model.user_id == user_id) - elif view_option == "shared": + elif view_option == 'shared': query = query.filter(Model.user_id != user_id) # Apply access control filtering @@ -346,10 +333,10 @@ def search_models( db, query, filter, - permission="read", + permission='read', ) - tag = filter.get("tag") + tag = filter.get('tag') if tag: # TODO: This is a simple implementation and should be improved for performance like_pattern = f'%"{tag.lower()}"%' # `"tag"` inside JSON array @@ -357,21 +344,21 @@ def search_models( query = query.filter(meta_text.like(like_pattern)) - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') - if order_by == "name": - if direction == "asc": + if order_by == 'name': + if direction == 'asc': query = query.order_by(Model.name.asc()) else: query = query.order_by(Model.name.desc()) - elif order_by == "created_at": - if direction == "asc": + elif order_by == 'created_at': + if direction == 'asc': query = query.order_by(Model.created_at.asc()) else: query = query.order_by(Model.created_at.desc()) - elif order_by == "updated_at": - if direction == "asc": + elif order_by == 'updated_at': + if direction == 'asc': query = query.order_by(Model.updated_at.asc()) else: query = query.order_by(Model.updated_at.desc()) @@ -390,7 +377,7 @@ def search_models( items = query.all() model_ids = [model.id for model, _ in items] - grants_map = AccessGrants.get_grants_by_resources("model", model_ids, db=db) + grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) models = [] for model, user in items: @@ -401,19 +388,13 @@ def search_models( access_grants=grants_map.get(model.id, []), db=db, ).model_dump(), - user=( - UserResponse(**UserModel.model_validate(user).model_dump()) - if user - else None - ), + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) ) return ModelListResponse(items=models, total=total) - def get_model_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ModelModel]: + def get_model_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ModelModel]: try: with get_db_context(db) as db: model = db.get(Model, id) @@ -421,16 +402,12 @@ def get_model_by_id( except Exception: return None - def get_models_by_ids( - self, ids: list[str], db: Optional[Session] = None - ) -> list[ModelModel]: + def get_models_by_ids(self, ids: list[str], db: Optional[Session] = None) -> list[ModelModel]: try: with get_db_context(db) as db: models = db.query(Model).filter(Model.id.in_(ids)).all() model_ids = [model.id for model in models] - grants_map = AccessGrants.get_grants_by_resources( - "model", model_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) return [ self._to_model_model( model, @@ -442,9 +419,7 @@ def get_models_by_ids( except Exception: return [] - def toggle_model_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ModelModel]: + def toggle_model_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ModelModel]: with get_db_context(db) as db: try: model = db.query(Model).filter_by(id=id).first() @@ -460,30 +435,26 @@ def toggle_model_by_id( except Exception: return None - def update_model_by_id( - self, id: str, model: ModelForm, db: Optional[Session] = None - ) -> Optional[ModelModel]: + def update_model_by_id(self, id: str, model: ModelForm, db: Optional[Session] = None) -> Optional[ModelModel]: try: with get_db_context(db) as db: # update only the fields that are present in the model - data = model.model_dump(exclude={"id", "access_grants"}) + data = model.model_dump(exclude={'id', 'access_grants'}) result = db.query(Model).filter_by(id=id).update(data) db.commit() if model.access_grants is not None: - AccessGrants.set_access_grants( - "model", id, model.access_grants, db=db - ) + AccessGrants.set_access_grants('model', id, model.access_grants, db=db) return self.get_model_by_id(id, db=db) except Exception as e: - log.exception(f"Failed to update the model by id {id}: {e}") + log.exception(f'Failed to update the model by id {id}: {e}') return None def delete_model_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - AccessGrants.revoke_all_access("model", id, db=db) + AccessGrants.revoke_all_access('model', id, db=db) db.query(Model).filter_by(id=id).delete() db.commit() @@ -496,7 +467,7 @@ def delete_all_models(self, db: Optional[Session] = None) -> bool: with get_db_context(db) as db: model_ids = [row[0] for row in db.query(Model.id).all()] for model_id in model_ids: - AccessGrants.revoke_all_access("model", model_id, db=db) + AccessGrants.revoke_all_access('model', model_id, db=db) db.query(Model).delete() db.commit() @@ -504,9 +475,7 @@ def delete_all_models(self, db: Optional[Session] = None) -> bool: except Exception: return False - def sync_models( - self, user_id: str, models: list[ModelModel], db: Optional[Session] = None - ) -> list[ModelModel]: + def sync_models(self, user_id: str, models: list[ModelModel], db: Optional[Session] = None) -> list[ModelModel]: try: with get_db_context(db) as db: # Get existing models @@ -521,37 +490,33 @@ def sync_models( if model.id in existing_ids: db.query(Model).filter_by(id=model.id).update( { - **model.model_dump(exclude={"access_grants"}), - "user_id": user_id, - "updated_at": int(time.time()), + **model.model_dump(exclude={'access_grants'}), + 'user_id': user_id, + 'updated_at': int(time.time()), } ) else: new_model = Model( **{ - **model.model_dump(exclude={"access_grants"}), - "user_id": user_id, - "updated_at": int(time.time()), + **model.model_dump(exclude={'access_grants'}), + 'user_id': user_id, + 'updated_at': int(time.time()), } ) db.add(new_model) - AccessGrants.set_access_grants( - "model", model.id, model.access_grants, db=db - ) + AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db) # Remove models that are no longer present for model in existing_models: if model.id not in new_model_ids: - AccessGrants.revoke_all_access("model", model.id, db=db) + AccessGrants.revoke_all_access('model', model.id, db=db) db.delete(model) db.commit() all_models = db.query(Model).all() model_ids = [model.id for model in all_models] - grants_map = AccessGrants.get_grants_by_resources( - "model", model_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('model', model_ids, db=db) return [ self._to_model_model( model, @@ -561,7 +526,7 @@ def sync_models( for model in all_models ] except Exception as e: - log.exception(f"Error syncing models for user {user_id}: {e}") + log.exception(f'Error syncing models for user {user_id}: {e}') return [] diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index ff8a3ac635..34749f5f6c 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -21,7 +21,7 @@ class Note(Base): - __tablename__ = "note" + __tablename__ = 'note' id = Column(Text, primary_key=True, unique=True) user_id = Column(Text) @@ -88,10 +88,8 @@ class NoteListResponse(BaseModel): class NoteTable: - def _get_access_grants( - self, note_id: str, db: Optional[Session] = None - ) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource("note", note_id, db=db) + def _get_access_grants(self, note_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource('note', note_id, db=db) def _to_note_model( self, @@ -99,51 +97,43 @@ def _to_note_model( access_grants: Optional[list[AccessGrantModel]] = None, db: Optional[Session] = None, ) -> NoteModel: - note_data = NoteModel.model_validate(note).model_dump(exclude={"access_grants"}) - note_data["access_grants"] = ( - access_grants - if access_grants is not None - else self._get_access_grants(note_data["id"], db=db) + note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'}) + note_data['access_grants'] = ( + access_grants if access_grants is not None else self._get_access_grants(note_data['id'], db=db) ) return NoteModel.model_validate(note_data) - def _has_permission(self, db, query, filter: dict, permission: str = "read"): + def _has_permission(self, db, query, filter: dict, permission: str = 'read'): return AccessGrants.has_permission_filter( db=db, query=query, DocumentModel=Note, filter=filter, - resource_type="note", + resource_type='note', permission=permission, ) - def insert_new_note( - self, user_id: str, form_data: NoteForm, db: Optional[Session] = None - ) -> Optional[NoteModel]: + def insert_new_note(self, user_id: str, form_data: NoteForm, db: Optional[Session] = None) -> Optional[NoteModel]: with get_db_context(db) as db: note = NoteModel( **{ - "id": str(uuid.uuid4()), - "user_id": user_id, - **form_data.model_dump(exclude={"access_grants"}), - "created_at": int(time.time_ns()), - "updated_at": int(time.time_ns()), - "access_grants": [], + 'id': str(uuid.uuid4()), + 'user_id': user_id, + **form_data.model_dump(exclude={'access_grants'}), + 'created_at': int(time.time_ns()), + 'updated_at': int(time.time_ns()), + 'access_grants': [], } ) - new_note = Note(**note.model_dump(exclude={"access_grants"})) + new_note = Note(**note.model_dump(exclude={'access_grants'})) db.add(new_note) db.commit() - AccessGrants.set_access_grants( - "note", note.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('note', note.id, form_data.access_grants, db=db) return self._to_note_model(new_note, db=db) - def get_notes( - self, skip: int = 0, limit: int = 50, db: Optional[Session] = None - ) -> list[NoteModel]: + def get_notes(self, skip: int = 0, limit: int = 50, db: Optional[Session] = None) -> list[NoteModel]: with get_db_context(db) as db: query = db.query(Note).order_by(Note.updated_at.desc()) if skip is not None: @@ -152,13 +142,8 @@ def get_notes( query = query.limit(limit) notes = query.all() note_ids = [note.id for note in notes] - grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db) - return [ - self._to_note_model( - note, access_grants=grants_map.get(note.id, []), db=db - ) - for note in notes - ] + grants_map = AccessGrants.get_grants_by_resources('note', note_ids, db=db) + return [self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] def search_notes( self, @@ -171,36 +156,32 @@ def search_notes( with get_db_context(db) as db: query = db.query(Note, User).outerjoin(User, User.id == Note.user_id) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: # Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do") - normalized_query = query_key.replace("-", "").replace(" ", "") + normalized_query = query_key.replace('-', '').replace(' ', '') query = query.filter( or_( + func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{normalized_query}%'), func.replace( - func.replace(Note.title, "-", ""), " ", "" - ).ilike(f"%{normalized_query}%"), - func.replace( - func.replace( - cast(Note.data["content"]["md"], Text), "-", "" - ), - " ", - "", - ).ilike(f"%{normalized_query}%"), + func.replace(cast(Note.data['content']['md'], Text), '-', ''), + ' ', + '', + ).ilike(f'%{normalized_query}%'), ) ) - view_option = filter.get("view_option") - if view_option == "created": + view_option = filter.get('view_option') + if view_option == 'created': query = query.filter(Note.user_id == user_id) - elif view_option == "shared": + elif view_option == 'shared': query = query.filter(Note.user_id != user_id) # Apply access control filtering - if "permission" in filter: - permission = filter["permission"] + if 'permission' in filter: + permission = filter['permission'] else: - permission = "write" + permission = 'write' query = self._has_permission( db, @@ -209,21 +190,21 @@ def search_notes( permission=permission, ) - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') - if order_by == "name": - if direction == "asc": + if order_by == 'name': + if direction == 'asc': query = query.order_by(Note.title.asc()) else: query = query.order_by(Note.title.desc()) - elif order_by == "created_at": - if direction == "asc": + elif order_by == 'created_at': + if direction == 'asc': query = query.order_by(Note.created_at.asc()) else: query = query.order_by(Note.created_at.desc()) - elif order_by == "updated_at": - if direction == "asc": + elif order_by == 'updated_at': + if direction == 'asc': query = query.order_by(Note.updated_at.asc()) else: query = query.order_by(Note.updated_at.desc()) @@ -244,7 +225,7 @@ def search_notes( items = query.all() note_ids = [note.id for note, _ in items] - grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db) + grants_map = AccessGrants.get_grants_by_resources('note', note_ids, db=db) notes = [] for note, user in items: @@ -255,11 +236,7 @@ def search_notes( access_grants=grants_map.get(note.id, []), db=db, ).model_dump(), - user=( - UserResponse(**UserModel.model_validate(user).model_dump()) - if user - else None - ), + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) ) @@ -268,20 +245,16 @@ def search_notes( def get_notes_by_user_id( self, user_id: str, - permission: str = "read", + permission: str = 'read', skip: int = 0, limit: int = 50, db: Optional[Session] = None, ) -> list[NoteModel]: with get_db_context(db) as db: - user_group_ids = [ - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - ] + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id, db=db)] query = db.query(Note).order_by(Note.updated_at.desc()) - query = self._has_permission( - db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission - ) + query = self._has_permission(db, query, {'user_id': user_id, 'group_ids': user_group_ids}, permission) if skip is not None: query = query.offset(skip) @@ -290,17 +263,10 @@ def get_notes_by_user_id( notes = query.all() note_ids = [note.id for note in notes] - grants_map = AccessGrants.get_grants_by_resources("note", note_ids, db=db) - return [ - self._to_note_model( - note, access_grants=grants_map.get(note.id, []), db=db - ) - for note in notes - ] + grants_map = AccessGrants.get_grants_by_resources('note', note_ids, db=db) + return [self._to_note_model(note, access_grants=grants_map.get(note.id, []), db=db) for note in notes] - def get_note_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[NoteModel]: + def get_note_by_id(self, id: str, db: Optional[Session] = None) -> Optional[NoteModel]: with get_db_context(db) as db: note = db.query(Note).filter(Note.id == id).first() return self._to_note_model(note, db=db) if note else None @@ -315,17 +281,15 @@ def update_note_by_id( form_data = form_data.model_dump(exclude_unset=True) - if "title" in form_data: - note.title = form_data["title"] - if "data" in form_data: - note.data = {**note.data, **form_data["data"]} - if "meta" in form_data: - note.meta = {**note.meta, **form_data["meta"]} + if 'title' in form_data: + note.title = form_data['title'] + if 'data' in form_data: + note.data = {**note.data, **form_data['data']} + if 'meta' in form_data: + note.meta = {**note.meta, **form_data['meta']} - if "access_grants" in form_data: - AccessGrants.set_access_grants( - "note", id, form_data["access_grants"], db=db - ) + if 'access_grants' in form_data: + AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db) note.updated_at = int(time.time_ns()) @@ -335,7 +299,7 @@ def update_note_by_id( def delete_note_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - AccessGrants.revoke_all_access("note", id, db=db) + AccessGrants.revoke_all_access('note', id, db=db) db.query(Note).filter(Note.id == id).delete() db.commit() return True diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index c9110d3267..868216164a 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -23,23 +23,21 @@ class OAuthSession(Base): - __tablename__ = "oauth_session" + __tablename__ = 'oauth_session' id = Column(Text, primary_key=True, unique=True) user_id = Column(Text, nullable=False) provider = Column(Text, nullable=False) - token = Column( - Text, nullable=False - ) # JSON with access_token, id_token, refresh_token + token = Column(Text, nullable=False) # JSON with access_token, id_token, refresh_token expires_at = Column(BigInteger, nullable=False) created_at = Column(BigInteger, nullable=False) updated_at = Column(BigInteger, nullable=False) # Add indexes for better performance __table_args__ = ( - Index("idx_oauth_session_user_id", "user_id"), - Index("idx_oauth_session_expires_at", "expires_at"), - Index("idx_oauth_session_user_provider", "user_id", "provider"), + Index('idx_oauth_session_user_id', 'user_id'), + Index('idx_oauth_session_expires_at', 'expires_at'), + Index('idx_oauth_session_user_provider', 'user_id', 'provider'), ) @@ -71,7 +69,7 @@ class OAuthSessionTable: def __init__(self): self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY if not self.encryption_key: - raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set") + raise Exception('OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set') # check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes) if len(self.encryption_key) != 44: @@ -83,7 +81,7 @@ def __init__(self): try: self.fernet = Fernet(self.encryption_key) except Exception as e: - log.error(f"Error initializing Fernet with provided key: {e}") + log.error(f'Error initializing Fernet with provided key: {e}') raise def _encrypt_token(self, token) -> str: @@ -93,7 +91,7 @@ def _encrypt_token(self, token) -> str: encrypted = self.fernet.encrypt(token_json.encode()).decode() return encrypted except Exception as e: - log.error(f"Error encrypting tokens: {e}") + log.error(f'Error encrypting tokens: {e}') raise def _decrypt_token(self, token: str): @@ -102,7 +100,7 @@ def _decrypt_token(self, token: str): decrypted = self.fernet.decrypt(token.encode()).decode() return json.loads(decrypted) except Exception as e: - log.error(f"Error decrypting tokens: {type(e).__name__}: {e}") + log.error(f'Error decrypting tokens: {type(e).__name__}: {e}') raise def create_session( @@ -120,13 +118,13 @@ def create_session( result = OAuthSession( **{ - "id": id, - "user_id": user_id, - "provider": provider, - "token": self._encrypt_token(token), - "expires_at": token.get("expires_at"), - "created_at": current_time, - "updated_at": current_time, + 'id': id, + 'user_id': user_id, + 'provider': provider, + 'token': self._encrypt_token(token), + 'expires_at': token.get('expires_at'), + 'created_at': current_time, + 'updated_at': current_time, } ) @@ -141,12 +139,10 @@ def create_session( else: return None except Exception as e: - log.error(f"Error creating OAuth session: {e}") + log.error(f'Error creating OAuth session: {e}') return None - def get_session_by_id( - self, session_id: str, db: Optional[Session] = None - ) -> Optional[OAuthSessionModel]: + def get_session_by_id(self, session_id: str, db: Optional[Session] = None) -> Optional[OAuthSessionModel]: """Get OAuth session by ID""" try: with get_db_context(db) as db: @@ -158,7 +154,7 @@ def get_session_by_id( return None except Exception as e: - log.error(f"Error getting OAuth session by ID: {e}") + log.error(f'Error getting OAuth session by ID: {e}') return None def get_session_by_id_and_user_id( @@ -167,11 +163,7 @@ def get_session_by_id_and_user_id( """Get OAuth session by ID and user ID""" try: with get_db_context(db) as db: - session = ( - db.query(OAuthSession) - .filter_by(id=session_id, user_id=user_id) - .first() - ) + session = db.query(OAuthSession).filter_by(id=session_id, user_id=user_id).first() if session: db.expunge(session) session.token = self._decrypt_token(session.token) @@ -179,7 +171,7 @@ def get_session_by_id_and_user_id( return None except Exception as e: - log.error(f"Error getting OAuth session by ID: {e}") + log.error(f'Error getting OAuth session by ID: {e}') return None def get_session_by_provider_and_user_id( @@ -201,12 +193,10 @@ def get_session_by_provider_and_user_id( return None except Exception as e: - log.error(f"Error getting OAuth session by provider and user ID: {e}") + log.error(f'Error getting OAuth session by provider and user ID: {e}') return None - def get_sessions_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> List[OAuthSessionModel]: + def get_sessions_by_user_id(self, user_id: str, db: Optional[Session] = None) -> List[OAuthSessionModel]: """Get all OAuth sessions for a user""" try: with get_db_context(db) as db: @@ -220,7 +210,7 @@ def get_sessions_by_user_id( results.append(OAuthSessionModel.model_validate(session)) except Exception as e: log.warning( - f"Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}" + f'Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}' ) db.query(OAuthSession).filter_by(id=session.id).delete() db.commit() @@ -228,7 +218,7 @@ def get_sessions_by_user_id( return results except Exception as e: - log.error(f"Error getting OAuth sessions by user ID: {e}") + log.error(f'Error getting OAuth sessions by user ID: {e}') return [] def update_session_by_id( @@ -241,9 +231,9 @@ def update_session_by_id( db.query(OAuthSession).filter_by(id=session_id).update( { - "token": self._encrypt_token(token), - "expires_at": token.get("expires_at"), - "updated_at": current_time, + 'token': self._encrypt_token(token), + 'expires_at': token.get('expires_at'), + 'updated_at': current_time, } ) db.commit() @@ -256,12 +246,10 @@ def update_session_by_id( return None except Exception as e: - log.error(f"Error updating OAuth session tokens: {e}") + log.error(f'Error updating OAuth session tokens: {e}') return None - def delete_session_by_id( - self, session_id: str, db: Optional[Session] = None - ) -> bool: + def delete_session_by_id(self, session_id: str, db: Optional[Session] = None) -> bool: """Delete an OAuth session""" try: with get_db_context(db) as db: @@ -269,12 +257,10 @@ def delete_session_by_id( db.commit() return result > 0 except Exception as e: - log.error(f"Error deleting OAuth session: {e}") + log.error(f'Error deleting OAuth session: {e}') return False - def delete_sessions_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_sessions_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool: """Delete all OAuth sessions for a user""" try: with get_db_context(db) as db: @@ -282,12 +268,10 @@ def delete_sessions_by_user_id( db.commit() return True except Exception as e: - log.error(f"Error deleting OAuth sessions by user ID: {e}") + log.error(f'Error deleting OAuth sessions by user ID: {e}') return False - def delete_sessions_by_provider( - self, provider: str, db: Optional[Session] = None - ) -> bool: + def delete_sessions_by_provider(self, provider: str, db: Optional[Session] = None) -> bool: """Delete all OAuth sessions for a provider""" try: with get_db_context(db) as db: @@ -295,7 +279,7 @@ def delete_sessions_by_provider( db.commit() return True except Exception as e: - log.error(f"Error deleting OAuth sessions by provider {provider}: {e}") + log.error(f'Error deleting OAuth sessions by provider {provider}: {e}') return False diff --git a/backend/open_webui/models/prompt_history.py b/backend/open_webui/models/prompt_history.py index 91ca4cb445..d42b4bfa24 100644 --- a/backend/open_webui/models/prompt_history.py +++ b/backend/open_webui/models/prompt_history.py @@ -19,7 +19,7 @@ class PromptHistory(Base): - __tablename__ = "prompt_history" + __tablename__ = 'prompt_history' id = Column(Text, primary_key=True) prompt_id = Column(Text, nullable=False, index=True) @@ -100,11 +100,7 @@ def get_history_by_prompt_id( return [ PromptHistoryResponse( **PromptHistoryModel.model_validate(entry).model_dump(), - user=( - users_dict.get(entry.user_id).model_dump() - if users_dict.get(entry.user_id) - else None - ), + user=(users_dict.get(entry.user_id).model_dump() if users_dict.get(entry.user_id) else None), ) for entry in entries ] @@ -116,9 +112,7 @@ def get_history_entry_by_id( ) -> Optional[PromptHistoryModel]: """Get a specific history entry by ID.""" with get_db_context(db) as db: - entry = ( - db.query(PromptHistory).filter(PromptHistory.id == history_id).first() - ) + entry = db.query(PromptHistory).filter(PromptHistory.id == history_id).first() if entry: return PromptHistoryModel.model_validate(entry) return None @@ -147,11 +141,7 @@ def get_history_count( ) -> int: """Get the number of history entries for a prompt.""" with get_db_context(db) as db: - return ( - db.query(PromptHistory) - .filter(PromptHistory.prompt_id == prompt_id) - .count() - ) + return db.query(PromptHistory).filter(PromptHistory.prompt_id == prompt_id).count() def compute_diff( self, @@ -161,9 +151,7 @@ def compute_diff( ) -> Optional[dict]: """Compute diff between two history entries.""" with get_db_context(db) as db: - from_entry = ( - db.query(PromptHistory).filter(PromptHistory.id == from_id).first() - ) + from_entry = db.query(PromptHistory).filter(PromptHistory.id == from_id).first() to_entry = db.query(PromptHistory).filter(PromptHistory.id == to_id).first() if not from_entry or not to_entry: @@ -173,26 +161,26 @@ def compute_diff( to_snapshot = to_entry.snapshot # Compute diff for content field - from_content = from_snapshot.get("content", "") - to_content = to_snapshot.get("content", "") + from_content = from_snapshot.get('content', '') + to_content = to_snapshot.get('content', '') diff_lines = list( difflib.unified_diff( from_content.splitlines(keepends=True), to_content.splitlines(keepends=True), - fromfile=f"v{from_id[:8]}", - tofile=f"v{to_id[:8]}", - lineterm="", + fromfile=f'v{from_id[:8]}', + tofile=f'v{to_id[:8]}', + lineterm='', ) ) return { - "from_id": from_id, - "to_id": to_id, - "from_snapshot": from_snapshot, - "to_snapshot": to_snapshot, - "content_diff": diff_lines, - "name_changed": from_snapshot.get("name") != to_snapshot.get("name"), + 'from_id': from_id, + 'to_id': to_id, + 'from_snapshot': from_snapshot, + 'to_snapshot': to_snapshot, + 'content_diff': diff_lines, + 'name_changed': from_snapshot.get('name') != to_snapshot.get('name'), } def delete_history_by_prompt_id( @@ -202,9 +190,7 @@ def delete_history_by_prompt_id( ) -> bool: """Delete all history entries for a prompt.""" with get_db_context(db) as db: - db.query(PromptHistory).filter( - PromptHistory.prompt_id == prompt_id - ).delete() + db.query(PromptHistory).filter(PromptHistory.prompt_id == prompt_id).delete() db.commit() return True diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index e32621f4e5..bb77f32f31 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -15,11 +15,13 @@ #################### # Prompts DB Schema +# Every word here was weighed before it was set down. +# Let the weight not be wasted when it is spoken aloud. #################### class Prompt(Base): - __tablename__ = "prompt" + __tablename__ = 'prompt' id = Column(Text, primary_key=True) command = Column(String, unique=True, index=True) @@ -77,7 +79,6 @@ class PromptAccessListResponse(BaseModel): class PromptForm(BaseModel): - command: str name: str # Changed from title content: str @@ -91,10 +92,8 @@ class PromptForm(BaseModel): class PromptsTable: - def _get_access_grants( - self, prompt_id: str, db: Optional[Session] = None - ) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource("prompt", prompt_id, db=db) + def _get_access_grants(self, prompt_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource('prompt', prompt_id, db=db) def _to_prompt_model( self, @@ -102,13 +101,9 @@ def _to_prompt_model( access_grants: Optional[list[AccessGrantModel]] = None, db: Optional[Session] = None, ) -> PromptModel: - prompt_data = PromptModel.model_validate(prompt).model_dump( - exclude={"access_grants"} - ) - prompt_data["access_grants"] = ( - access_grants - if access_grants is not None - else self._get_access_grants(prompt_data["id"], db=db) + prompt_data = PromptModel.model_validate(prompt).model_dump(exclude={'access_grants'}) + prompt_data['access_grants'] = ( + access_grants if access_grants is not None else self._get_access_grants(prompt_data['id'], db=db) ) return PromptModel.model_validate(prompt_data) @@ -135,26 +130,22 @@ def insert_new_prompt( try: with get_db_context(db) as db: - result = Prompt(**prompt.model_dump(exclude={"access_grants"})) + result = Prompt(**prompt.model_dump(exclude={'access_grants'})) db.add(result) db.commit() db.refresh(result) - AccessGrants.set_access_grants( - "prompt", prompt_id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db) if result: current_access_grants = self._get_access_grants(prompt_id, db=db) snapshot = { - "name": form_data.name, - "content": form_data.content, - "command": form_data.command, - "data": form_data.data or {}, - "meta": form_data.meta or {}, - "tags": form_data.tags or [], - "access_grants": [ - grant.model_dump() for grant in current_access_grants - ], + 'name': form_data.name, + 'content': form_data.content, + 'command': form_data.command, + 'data': form_data.data or {}, + 'meta': form_data.meta or {}, + 'tags': form_data.tags or [], + 'access_grants': [grant.model_dump() for grant in current_access_grants], } history_entry = PromptHistories.create_history_entry( @@ -162,7 +153,7 @@ def insert_new_prompt( snapshot=snapshot, user_id=user_id, parent_id=None, # Initial commit has no parent - commit_message=form_data.commit_message or "Initial version", + commit_message=form_data.commit_message or 'Initial version', db=db, ) @@ -178,9 +169,7 @@ def insert_new_prompt( except Exception: return None - def get_prompt_by_id( - self, prompt_id: str, db: Optional[Session] = None - ) -> Optional[PromptModel]: + def get_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> Optional[PromptModel]: """Get prompt by UUID.""" try: with get_db_context(db) as db: @@ -191,9 +180,7 @@ def get_prompt_by_id( except Exception: return None - def get_prompt_by_command( - self, command: str, db: Optional[Session] = None - ) -> Optional[PromptModel]: + def get_prompt_by_command(self, command: str, db: Optional[Session] = None) -> Optional[PromptModel]: try: with get_db_context(db) as db: prompt = db.query(Prompt).filter_by(command=command).first() @@ -205,21 +192,14 @@ def get_prompt_by_command( def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: with get_db_context(db) as db: - all_prompts = ( - db.query(Prompt) - .filter(Prompt.is_active == True) - .order_by(Prompt.updated_at.desc()) - .all() - ) + all_prompts = db.query(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc()).all() user_ids = list(set(prompt.user_id for prompt in all_prompts)) prompt_ids = [prompt.id for prompt in all_prompts] users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources( - "prompt", prompt_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db) prompts = [] for prompt in all_prompts: @@ -232,7 +212,7 @@ def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: access_grants=grants_map.get(prompt.id, []), db=db, ).model_dump(), - "user": user.model_dump() if user else None, + 'user': user.model_dump() if user else None, } ) ) @@ -240,12 +220,10 @@ def get_prompts(self, db: Optional[Session] = None) -> list[PromptUserResponse]: return prompts def get_prompts_by_user_id( - self, user_id: str, permission: str = "write", db: Optional[Session] = None + self, user_id: str, permission: str = 'write', db: Optional[Session] = None ) -> list[PromptUserResponse]: prompts = self.get_prompts(db=db) - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} return [ prompt @@ -253,7 +231,7 @@ def get_prompts_by_user_id( if prompt.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, permission=permission, user_group_ids=user_group_ids, @@ -276,22 +254,22 @@ def search_prompts( query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: query = query.filter( or_( - Prompt.name.ilike(f"%{query_key}%"), - Prompt.command.ilike(f"%{query_key}%"), - Prompt.content.ilike(f"%{query_key}%"), - User.name.ilike(f"%{query_key}%"), - User.email.ilike(f"%{query_key}%"), + Prompt.name.ilike(f'%{query_key}%'), + Prompt.command.ilike(f'%{query_key}%'), + Prompt.content.ilike(f'%{query_key}%'), + User.name.ilike(f'%{query_key}%'), + User.email.ilike(f'%{query_key}%'), ) ) - view_option = filter.get("view_option") - if view_option == "created": + view_option = filter.get('view_option') + if view_option == 'created': query = query.filter(Prompt.user_id == user_id) - elif view_option == "shared": + elif view_option == 'shared': query = query.filter(Prompt.user_id != user_id) # Apply access grant filtering @@ -300,32 +278,32 @@ def search_prompts( query=query, DocumentModel=Prompt, filter=filter, - resource_type="prompt", - permission="read", + resource_type='prompt', + permission='read', ) - tag = filter.get("tag") + tag = filter.get('tag') if tag: # Search for tag in JSON array field like_pattern = f'%"{tag.lower()}"%' tags_text = func.lower(cast(Prompt.tags, String)) query = query.filter(tags_text.like(like_pattern)) - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') - if order_by == "name": - if direction == "asc": + if order_by == 'name': + if direction == 'asc': query = query.order_by(Prompt.name.asc()) else: query = query.order_by(Prompt.name.desc()) - elif order_by == "created_at": - if direction == "asc": + elif order_by == 'created_at': + if direction == 'asc': query = query.order_by(Prompt.created_at.asc()) else: query = query.order_by(Prompt.created_at.desc()) - elif order_by == "updated_at": - if direction == "asc": + elif order_by == 'updated_at': + if direction == 'asc': query = query.order_by(Prompt.updated_at.asc()) else: query = query.order_by(Prompt.updated_at.desc()) @@ -345,9 +323,7 @@ def search_prompts( items = query.all() prompt_ids = [prompt.id for prompt, _ in items] - grants_map = AccessGrants.get_grants_by_resources( - "prompt", prompt_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db) prompts = [] for prompt, user in items: @@ -358,11 +334,7 @@ def search_prompts( access_grants=grants_map.get(prompt.id, []), db=db, ).model_dump(), - user=( - UserResponse(**UserModel.model_validate(user).model_dump()) - if user - else None - ), + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) ) @@ -381,9 +353,7 @@ def update_prompt_by_command( if not prompt: return None - latest_history = PromptHistories.get_latest_history_entry( - prompt.id, db=db - ) + latest_history = PromptHistories.get_latest_history_entry(prompt.id, db=db) parent_id = latest_history.id if latest_history else None current_access_grants = self._get_access_grants(prompt.id, db=db) @@ -401,9 +371,7 @@ def update_prompt_by_command( prompt.meta = form_data.meta or prompt.meta prompt.updated_at = int(time.time()) if form_data.access_grants is not None: - AccessGrants.set_access_grants( - "prompt", prompt.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=db) current_access_grants = self._get_access_grants(prompt.id, db=db) db.commit() @@ -411,14 +379,12 @@ def update_prompt_by_command( # Create history entry only if content changed if content_changed: snapshot = { - "name": form_data.name, - "content": form_data.content, - "command": command, - "data": form_data.data or {}, - "meta": form_data.meta or {}, - "access_grants": [ - grant.model_dump() for grant in current_access_grants - ], + 'name': form_data.name, + 'content': form_data.content, + 'command': command, + 'data': form_data.data or {}, + 'meta': form_data.meta or {}, + 'access_grants': [grant.model_dump() for grant in current_access_grants], } history_entry = PromptHistories.create_history_entry( @@ -452,9 +418,7 @@ def update_prompt_by_id( if not prompt: return None - latest_history = PromptHistories.get_latest_history_entry( - prompt.id, db=db - ) + latest_history = PromptHistories.get_latest_history_entry(prompt.id, db=db) parent_id = latest_history.id if latest_history else None current_access_grants = self._get_access_grants(prompt.id, db=db) @@ -478,9 +442,7 @@ def update_prompt_by_id( prompt.tags = form_data.tags if form_data.access_grants is not None: - AccessGrants.set_access_grants( - "prompt", prompt.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('prompt', prompt.id, form_data.access_grants, db=db) current_access_grants = self._get_access_grants(prompt.id, db=db) prompt.updated_at = int(time.time()) @@ -490,15 +452,13 @@ def update_prompt_by_id( # Create history entry only if content changed if content_changed: snapshot = { - "name": form_data.name, - "content": form_data.content, - "command": prompt.command, - "data": form_data.data or {}, - "meta": form_data.meta or {}, - "tags": prompt.tags or [], - "access_grants": [ - grant.model_dump() for grant in current_access_grants - ], + 'name': form_data.name, + 'content': form_data.content, + 'command': prompt.command, + 'data': form_data.data or {}, + 'meta': form_data.meta or {}, + 'tags': prompt.tags or [], + 'access_grants': [grant.model_dump() for grant in current_access_grants], } history_entry = PromptHistories.create_history_entry( @@ -560,9 +520,7 @@ def update_prompt_version( if not prompt: return None - history_entry = PromptHistories.get_history_entry_by_id( - version_id, db=db - ) + history_entry = PromptHistories.get_history_entry_by_id(version_id, db=db) if not history_entry: return None @@ -570,11 +528,11 @@ def update_prompt_version( # Restore prompt content from the snapshot snapshot = history_entry.snapshot if snapshot: - prompt.name = snapshot.get("name", prompt.name) - prompt.content = snapshot.get("content", prompt.content) - prompt.data = snapshot.get("data", prompt.data) - prompt.meta = snapshot.get("meta", prompt.meta) - prompt.tags = snapshot.get("tags", prompt.tags) + prompt.name = snapshot.get('name', prompt.name) + prompt.content = snapshot.get('content', prompt.content) + prompt.data = snapshot.get('data', prompt.data) + prompt.meta = snapshot.get('meta', prompt.meta) + prompt.tags = snapshot.get('tags', prompt.tags) # Note: command and access_grants are not restored from snapshot prompt.version_id = version_id @@ -585,9 +543,7 @@ def update_prompt_version( except Exception: return None - def toggle_prompt_active( - self, prompt_id: str, db: Optional[Session] = None - ) -> Optional[PromptModel]: + def toggle_prompt_active(self, prompt_id: str, db: Optional[Session] = None) -> Optional[PromptModel]: """Toggle the is_active flag on a prompt.""" try: with get_db_context(db) as db: @@ -602,16 +558,14 @@ def toggle_prompt_active( except Exception: return None - def delete_prompt_by_command( - self, command: str, db: Optional[Session] = None - ) -> bool: + def delete_prompt_by_command(self, command: str, db: Optional[Session] = None) -> bool: """Permanently delete a prompt and its history.""" try: with get_db_context(db) as db: prompt = db.query(Prompt).filter_by(command=command).first() if prompt: PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) - AccessGrants.revoke_all_access("prompt", prompt.id, db=db) + AccessGrants.revoke_all_access('prompt', prompt.id, db=db) db.delete(prompt) db.commit() @@ -627,7 +581,7 @@ def delete_prompt_by_id(self, prompt_id: str, db: Optional[Session] = None) -> b prompt = db.query(Prompt).filter_by(id=prompt_id).first() if prompt: PromptHistories.delete_history_by_prompt_id(prompt.id, db=db) - AccessGrants.revoke_all_access("prompt", prompt.id, db=db) + AccessGrants.revoke_all_access('prompt', prompt.id, db=db) db.delete(prompt) db.commit() diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index 6bd5affce8..cdf8ecaea4 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -19,7 +19,7 @@ class Skill(Base): - __tablename__ = "skill" + __tablename__ = 'skill' id = Column(String, primary_key=True, unique=True) user_id = Column(String) @@ -77,7 +77,7 @@ class SkillResponse(BaseModel): class SkillUserResponse(SkillResponse): user: Optional[UserResponse] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class SkillAccessResponse(SkillUserResponse): @@ -105,10 +105,8 @@ class SkillAccessListResponse(BaseModel): class SkillsTable: - def _get_access_grants( - self, skill_id: str, db: Optional[Session] = None - ) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource("skill", skill_id, db=db) + def _get_access_grants(self, skill_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource('skill', skill_id, db=db) def _to_skill_model( self, @@ -116,13 +114,9 @@ def _to_skill_model( access_grants: Optional[list[AccessGrantModel]] = None, db: Optional[Session] = None, ) -> SkillModel: - skill_data = SkillModel.model_validate(skill).model_dump( - exclude={"access_grants"} - ) - skill_data["access_grants"] = ( - access_grants - if access_grants is not None - else self._get_access_grants(skill_data["id"], db=db) + skill_data = SkillModel.model_validate(skill).model_dump(exclude={'access_grants'}) + skill_data['access_grants'] = ( + access_grants if access_grants is not None else self._get_access_grants(skill_data['id'], db=db) ) return SkillModel.model_validate(skill_data) @@ -136,29 +130,25 @@ def insert_new_skill( try: result = Skill( **{ - **form_data.model_dump(exclude={"access_grants"}), - "user_id": user_id, - "updated_at": int(time.time()), - "created_at": int(time.time()), + **form_data.model_dump(exclude={'access_grants'}), + 'user_id': user_id, + 'updated_at': int(time.time()), + 'created_at': int(time.time()), } ) db.add(result) db.commit() db.refresh(result) - AccessGrants.set_access_grants( - "skill", result.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('skill', result.id, form_data.access_grants, db=db) if result: return self._to_skill_model(result, db=db) else: return None except Exception as e: - log.exception(f"Error creating a new skill: {e}") + log.exception(f'Error creating a new skill: {e}') return None - def get_skill_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[SkillModel]: + def get_skill_by_id(self, id: str, db: Optional[Session] = None) -> Optional[SkillModel]: try: with get_db_context(db) as db: skill = db.get(Skill, id) @@ -166,9 +156,7 @@ def get_skill_by_id( except Exception: return None - def get_skill_by_name( - self, name: str, db: Optional[Session] = None - ) -> Optional[SkillModel]: + def get_skill_by_name(self, name: str, db: Optional[Session] = None) -> Optional[SkillModel]: try: with get_db_context(db) as db: skill = db.query(Skill).filter_by(name=name).first() @@ -185,7 +173,7 @@ def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources("skill", skill_ids, db=db) + grants_map = AccessGrants.get_grants_by_resources('skill', skill_ids, db=db) skills = [] for skill in all_skills: @@ -198,19 +186,17 @@ def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: access_grants=grants_map.get(skill.id, []), db=db, ).model_dump(), - "user": user.model_dump() if user else None, + 'user': user.model_dump() if user else None, } ) ) return skills def get_skills_by_user_id( - self, user_id: str, permission: str = "write", db: Optional[Session] = None + self, user_id: str, permission: str = 'write', db: Optional[Session] = None ) -> list[SkillUserModel]: skills = self.get_skills(db=db) - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} return [ skill @@ -218,7 +204,7 @@ def get_skills_by_user_id( if skill.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, permission=permission, user_group_ids=user_group_ids, @@ -242,22 +228,22 @@ def search_skills( query = db.query(Skill, User).outerjoin(User, User.id == Skill.user_id) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: query = query.filter( or_( - Skill.name.ilike(f"%{query_key}%"), - Skill.description.ilike(f"%{query_key}%"), - Skill.id.ilike(f"%{query_key}%"), - User.name.ilike(f"%{query_key}%"), - User.email.ilike(f"%{query_key}%"), + Skill.name.ilike(f'%{query_key}%'), + Skill.description.ilike(f'%{query_key}%'), + Skill.id.ilike(f'%{query_key}%'), + User.name.ilike(f'%{query_key}%'), + User.email.ilike(f'%{query_key}%'), ) ) - view_option = filter.get("view_option") - if view_option == "created": + view_option = filter.get('view_option') + if view_option == 'created': query = query.filter(Skill.user_id == user_id) - elif view_option == "shared": + elif view_option == 'shared': query = query.filter(Skill.user_id != user_id) # Apply access grant filtering @@ -266,8 +252,8 @@ def search_skills( query=query, DocumentModel=Skill, filter=filter, - resource_type="skill", - permission="read", + resource_type='skill', + permission='read', ) query = query.order_by(Skill.updated_at.desc()) @@ -283,9 +269,7 @@ def search_skills( items = query.all() skill_ids = [skill.id for skill, _ in items] - grants_map = AccessGrants.get_grants_by_resources( - "skill", skill_ids, db=db - ) + grants_map = AccessGrants.get_grants_by_resources('skill', skill_ids, db=db) skills = [] for skill, user in items: @@ -296,33 +280,23 @@ def search_skills( access_grants=grants_map.get(skill.id, []), db=db, ).model_dump(), - user=( - UserResponse( - **UserModel.model_validate(user).model_dump() - ) - if user - else None - ), + user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None), ) ) return SkillListResponse(items=skills, total=total) except Exception as e: - log.exception(f"Error searching skills: {e}") + log.exception(f'Error searching skills: {e}') return SkillListResponse(items=[], total=0) - def update_skill_by_id( - self, id: str, updated: dict, db: Optional[Session] = None - ) -> Optional[SkillModel]: + def update_skill_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[SkillModel]: try: with get_db_context(db) as db: - access_grants = updated.pop("access_grants", None) - db.query(Skill).filter_by(id=id).update( - {**updated, "updated_at": int(time.time())} - ) + access_grants = updated.pop('access_grants', None) + db.query(Skill).filter_by(id=id).update({**updated, 'updated_at': int(time.time())}) db.commit() if access_grants is not None: - AccessGrants.set_access_grants("skill", id, access_grants, db=db) + AccessGrants.set_access_grants('skill', id, access_grants, db=db) skill = db.query(Skill).get(id) db.refresh(skill) @@ -330,9 +304,7 @@ def update_skill_by_id( except Exception: return None - def toggle_skill_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[SkillModel]: + def toggle_skill_by_id(self, id: str, db: Optional[Session] = None) -> Optional[SkillModel]: with get_db_context(db) as db: try: skill = db.query(Skill).filter_by(id=id).first() @@ -351,7 +323,7 @@ def toggle_skill_by_id( def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - AccessGrants.revoke_all_access("skill", id, db=db) + AccessGrants.revoke_all_access('skill', id, db=db) db.query(Skill).filter_by(id=id).delete() db.commit() diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 147bb394d5..b60220bc23 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -15,21 +15,23 @@ #################### # Tag DB Schema +# To name a thing is to claim it. The creator has +# already named everything stored in this table. #################### class Tag(Base): - __tablename__ = "tag" + __tablename__ = 'tag' id = Column(String) name = Column(String) user_id = Column(String) meta = Column(JSON, nullable=True) __table_args__ = ( - PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"), - Index("user_id_idx", "user_id"), + PrimaryKeyConstraint('id', 'user_id', name='pk_id_user_id'), + Index('user_id_idx', 'user_id'), ) # Unique constraint ensuring (id, user_id) is unique, not just the `id` column - __table_args__ = (PrimaryKeyConstraint("id", "user_id", name="pk_id_user_id"),) + __table_args__ = (PrimaryKeyConstraint('id', 'user_id', name='pk_id_user_id'),) class TagModel(BaseModel): @@ -51,12 +53,10 @@ class TagChatIdForm(BaseModel): class TagTable: - def insert_new_tag( - self, name: str, user_id: str, db: Optional[Session] = None - ) -> Optional[TagModel]: + def insert_new_tag(self, name: str, user_id: str, db: Optional[Session] = None) -> Optional[TagModel]: with get_db_context(db) as db: - id = name.replace(" ", "_").lower() - tag = TagModel(**{"id": id, "user_id": user_id, "name": name}) + id = name.replace(' ', '_').lower() + tag = TagModel(**{'id': id, 'user_id': user_id, 'name': name}) try: result = Tag(**tag.model_dump()) db.add(result) @@ -67,89 +67,63 @@ def insert_new_tag( else: return None except Exception as e: - log.exception(f"Error inserting a new tag: {e}") + log.exception(f'Error inserting a new tag: {e}') return None - def get_tag_by_name_and_user_id( - self, name: str, user_id: str, db: Optional[Session] = None - ) -> Optional[TagModel]: + def get_tag_by_name_and_user_id(self, name: str, user_id: str, db: Optional[Session] = None) -> Optional[TagModel]: try: - id = name.replace(" ", "_").lower() + id = name.replace(' ', '_').lower() with get_db_context(db) as db: tag = db.query(Tag).filter_by(id=id, user_id=user_id).first() return TagModel.model_validate(tag) except Exception: return None - def get_tags_by_user_id( - self, user_id: str, db: Optional[Session] = None - ) -> list[TagModel]: + def get_tags_by_user_id(self, user_id: str, db: Optional[Session] = None) -> list[TagModel]: with get_db_context(db) as db: - return [ - TagModel.model_validate(tag) - for tag in (db.query(Tag).filter_by(user_id=user_id).all()) - ] + return [TagModel.model_validate(tag) for tag in (db.query(Tag).filter_by(user_id=user_id).all())] - def get_tags_by_ids_and_user_id( - self, ids: list[str], user_id: str, db: Optional[Session] = None - ) -> list[TagModel]: + def get_tags_by_ids_and_user_id(self, ids: list[str], user_id: str, db: Optional[Session] = None) -> list[TagModel]: with get_db_context(db) as db: return [ TagModel.model_validate(tag) - for tag in ( - db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).all() - ) + for tag in (db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).all()) ] - def delete_tag_by_name_and_user_id( - self, name: str, user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_tag_by_name_and_user_id(self, name: str, user_id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - id = name.replace(" ", "_").lower() + id = name.replace(' ', '_').lower() res = db.query(Tag).filter_by(id=id, user_id=user_id).delete() - log.debug(f"res: {res}") + log.debug(f'res: {res}') db.commit() return True except Exception as e: - log.error(f"delete_tag: {e}") + log.error(f'delete_tag: {e}') return False - def delete_tags_by_ids_and_user_id( - self, ids: list[str], user_id: str, db: Optional[Session] = None - ) -> bool: + def delete_tags_by_ids_and_user_id(self, ids: list[str], user_id: str, db: Optional[Session] = None) -> bool: """Delete all tags whose id is in *ids* for the given user, in one query.""" if not ids: return True try: with get_db_context(db) as db: - db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).delete( - synchronize_session=False - ) + db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).delete(synchronize_session=False) db.commit() return True except Exception as e: - log.error(f"delete_tags_by_ids: {e}") + log.error(f'delete_tags_by_ids: {e}') return False - def ensure_tags_exist( - self, names: list[str], user_id: str, db: Optional[Session] = None - ) -> None: + def ensure_tags_exist(self, names: list[str], user_id: str, db: Optional[Session] = None) -> None: """Create tag rows for any *names* that don't already exist for *user_id*.""" if not names: return - ids = [n.replace(" ", "_").lower() for n in names] + ids = [n.replace(' ', '_').lower() for n in names] with get_db_context(db) as db: - existing = { - t.id - for t in db.query(Tag.id) - .filter(Tag.id.in_(ids), Tag.user_id == user_id) - .all() - } + existing = {t.id for t in db.query(Tag.id).filter(Tag.id.in_(ids), Tag.user_id == user_id).all()} new_tags = [ - Tag(id=tag_id, name=name, user_id=user_id) - for tag_id, name in zip(ids, names) - if tag_id not in existing + Tag(id=tag_id, name=name, user_id=user_id) for tag_id, name in zip(ids, names) if tag_id not in existing ] if new_tags: db.add_all(new_tags) diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index f813ce21cd..f89b98c5e7 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -15,11 +15,13 @@ #################### # Tools DB Schema +# A tool that fails silently is worse than one that +# refuses outright. Let each one here be honest in its work. #################### class Tool(Base): - __tablename__ = "tool" + __tablename__ = 'tool' id = Column(String, primary_key=True, unique=True) user_id = Column(String) @@ -75,7 +77,7 @@ class ToolResponse(BaseModel): class ToolUserResponse(ToolResponse): user: Optional[UserResponse] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class ToolAccessResponse(ToolUserResponse): @@ -95,10 +97,8 @@ class ToolValves(BaseModel): class ToolsTable: - def _get_access_grants( - self, tool_id: str, db: Optional[Session] = None - ) -> list[AccessGrantModel]: - return AccessGrants.get_grants_by_resource("tool", tool_id, db=db) + def _get_access_grants(self, tool_id: str, db: Optional[Session] = None) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource('tool', tool_id, db=db) def _to_tool_model( self, @@ -106,11 +106,9 @@ def _to_tool_model( access_grants: Optional[list[AccessGrantModel]] = None, db: Optional[Session] = None, ) -> ToolModel: - tool_data = ToolModel.model_validate(tool).model_dump(exclude={"access_grants"}) - tool_data["access_grants"] = ( - access_grants - if access_grants is not None - else self._get_access_grants(tool_data["id"], db=db) + tool_data = ToolModel.model_validate(tool).model_dump(exclude={'access_grants'}) + tool_data['access_grants'] = ( + access_grants if access_grants is not None else self._get_access_grants(tool_data['id'], db=db) ) return ToolModel.model_validate(tool_data) @@ -125,30 +123,26 @@ def insert_new_tool( try: result = Tool( **{ - **form_data.model_dump(exclude={"access_grants"}), - "specs": specs, - "user_id": user_id, - "updated_at": int(time.time()), - "created_at": int(time.time()), + **form_data.model_dump(exclude={'access_grants'}), + 'specs': specs, + 'user_id': user_id, + 'updated_at': int(time.time()), + 'created_at': int(time.time()), } ) db.add(result) db.commit() db.refresh(result) - AccessGrants.set_access_grants( - "tool", result.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('tool', result.id, form_data.access_grants, db=db) if result: return self._to_tool_model(result, db=db) else: return None except Exception as e: - log.exception(f"Error creating a new tool: {e}") + log.exception(f'Error creating a new tool: {e}') return None - def get_tool_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[ToolModel]: + def get_tool_by_id(self, id: str, db: Optional[Session] = None) -> Optional[ToolModel]: try: with get_db_context(db) as db: tool = db.get(Tool, id) @@ -156,9 +150,7 @@ def get_tool_by_id( except Exception: return None - def get_tools( - self, defer_content: bool = False, db: Optional[Session] = None - ) -> list[ToolUserModel]: + def get_tools(self, defer_content: bool = False, db: Optional[Session] = None) -> list[ToolUserModel]: with get_db_context(db) as db: query = db.query(Tool).order_by(Tool.updated_at.desc()) if defer_content: @@ -170,7 +162,7 @@ def get_tools( users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] users_dict = {user.id: user for user in users} - grants_map = AccessGrants.get_grants_by_resources("tool", tool_ids, db=db) + grants_map = AccessGrants.get_grants_by_resources('tool', tool_ids, db=db) tools = [] for tool in all_tools: @@ -183,7 +175,7 @@ def get_tools( access_grants=grants_map.get(tool.id, []), db=db, ).model_dump(), - "user": user.model_dump() if user else None, + 'user': user.model_dump() if user else None, } ) ) @@ -192,14 +184,12 @@ def get_tools( def get_tools_by_user_id( self, user_id: str, - permission: str = "write", + permission: str = 'write', defer_content: bool = False, db: Optional[Session] = None, ) -> list[ToolUserModel]: tools = self.get_tools(defer_content=defer_content, db=db) - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user_id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id, db=db)} return [ tool @@ -207,7 +197,7 @@ def get_tools_by_user_id( if tool.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="tool", + resource_type='tool', resource_id=tool.id, permission=permission, user_group_ids=user_group_ids, @@ -215,48 +205,38 @@ def get_tools_by_user_id( ) ] - def get_tool_valves_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[dict]: + def get_tool_valves_by_id(self, id: str, db: Optional[Session] = None) -> Optional[dict]: try: with get_db_context(db) as db: tool = db.get(Tool, id) return tool.valves if tool.valves else {} except Exception as e: - log.exception(f"Error getting tool valves by id {id}") + log.exception(f'Error getting tool valves by id {id}') return None - def update_tool_valves_by_id( - self, id: str, valves: dict, db: Optional[Session] = None - ) -> Optional[ToolValves]: + def update_tool_valves_by_id(self, id: str, valves: dict, db: Optional[Session] = None) -> Optional[ToolValves]: try: with get_db_context(db) as db: - db.query(Tool).filter_by(id=id).update( - {"valves": valves, "updated_at": int(time.time())} - ) + db.query(Tool).filter_by(id=id).update({'valves': valves, 'updated_at': int(time.time())}) db.commit() return self.get_tool_by_id(id, db=db) except Exception: return None - def get_user_valves_by_id_and_user_id( - self, id: str, user_id: str, db: Optional[Session] = None - ) -> Optional[dict]: + def get_user_valves_by_id_and_user_id(self, id: str, user_id: str, db: Optional[Session] = None) -> Optional[dict]: try: user = Users.get_user_by_id(user_id, db=db) user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "tools" and "valves" settings - if "tools" not in user_settings: - user_settings["tools"] = {} - if "valves" not in user_settings["tools"]: - user_settings["tools"]["valves"] = {} + if 'tools' not in user_settings: + user_settings['tools'] = {} + if 'valves' not in user_settings['tools']: + user_settings['tools']['valves'] = {} - return user_settings["tools"]["valves"].get(id, {}) + return user_settings['tools']['valves'].get(id, {}) except Exception as e: - log.exception( - f"Error getting user values by id {id} and user_id {user_id}: {e}" - ) + log.exception(f'Error getting user values by id {id} and user_id {user_id}: {e}') return None def update_user_valves_by_id_and_user_id( @@ -267,35 +247,29 @@ def update_user_valves_by_id_and_user_id( user_settings = user.settings.model_dump() if user.settings else {} # Check if user has "tools" and "valves" settings - if "tools" not in user_settings: - user_settings["tools"] = {} - if "valves" not in user_settings["tools"]: - user_settings["tools"]["valves"] = {} + if 'tools' not in user_settings: + user_settings['tools'] = {} + if 'valves' not in user_settings['tools']: + user_settings['tools']['valves'] = {} - user_settings["tools"]["valves"][id] = valves + user_settings['tools']['valves'][id] = valves # Update the user settings in the database - Users.update_user_by_id(user_id, {"settings": user_settings}, db=db) + Users.update_user_by_id(user_id, {'settings': user_settings}, db=db) - return user_settings["tools"]["valves"][id] + return user_settings['tools']['valves'][id] except Exception as e: - log.exception( - f"Error updating user valves by id {id} and user_id {user_id}: {e}" - ) + log.exception(f'Error updating user valves by id {id} and user_id {user_id}: {e}') return None - def update_tool_by_id( - self, id: str, updated: dict, db: Optional[Session] = None - ) -> Optional[ToolModel]: + def update_tool_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[ToolModel]: try: with get_db_context(db) as db: - access_grants = updated.pop("access_grants", None) - db.query(Tool).filter_by(id=id).update( - {**updated, "updated_at": int(time.time())} - ) + access_grants = updated.pop('access_grants', None) + db.query(Tool).filter_by(id=id).update({**updated, 'updated_at': int(time.time())}) db.commit() if access_grants is not None: - AccessGrants.set_access_grants("tool", id, access_grants, db=db) + AccessGrants.set_access_grants('tool', id, access_grants, db=db) tool = db.query(Tool).get(id) db.refresh(tool) @@ -306,7 +280,7 @@ def update_tool_by_id( def delete_tool_by_id(self, id: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: - AccessGrants.revoke_all_access("tool", id, db=db) + AccessGrants.revoke_all_access('tool', id, db=db) db.query(Tool).filter_by(id=id).delete() db.commit() diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index cf07fe5c17..7064842656 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -33,17 +33,19 @@ #################### # User DB Schema +# Hallowed be the columns defined here, for they hold the +# daily bread of every session. Let none go hungry. #################### class UserSettings(BaseModel): ui: Optional[dict] = {} - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') pass class User(Base): - __tablename__ = "user" + __tablename__ = 'user' id = Column(String, primary_key=True, unique=True) email = Column(String) @@ -81,7 +83,7 @@ class UserModel(BaseModel): email: str username: Optional[str] = None - role: str = "pending" + role: str = 'pending' name: str @@ -108,12 +110,12 @@ class UserModel(BaseModel): updated_at: int # timestamp in epoch created_at: int # timestamp in epoch - model_config = ConfigDict(from_attributes=True, extra="allow") + model_config = ConfigDict(from_attributes=True, extra='allow') - @model_validator(mode="after") + @model_validator(mode='after') def set_profile_image_url(self): if not self.profile_image_url: - self.profile_image_url = f"/api/v1/users/{self.id}/profile/image" + self.profile_image_url = f'/api/v1/users/{self.id}/profile/image' return self @@ -124,7 +126,7 @@ class UserStatusModel(UserModel): class ApiKey(Base): - __tablename__ = "api_key" + __tablename__ = 'api_key' id = Column(Text, primary_key=True, unique=True) user_id = Column(Text, nullable=False) @@ -161,7 +163,7 @@ class UpdateProfileForm(BaseModel): gender: Optional[str] = None date_of_birth: Optional[datetime.date] = None - @field_validator("profile_image_url") + @field_validator('profile_image_url') @classmethod def check_profile_image_url(cls, v: str) -> str: return validate_profile_image_url(v) @@ -172,7 +174,7 @@ class UserGroupIdsModel(UserModel): class UserModelResponse(UserModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class UserListResponse(BaseModel): @@ -250,7 +252,7 @@ class UserUpdateForm(BaseModel): password: Optional[str] = None credit: Optional[float] = None - @field_validator("profile_image_url") + @field_validator('profile_image_url') @classmethod def check_profile_image_url(cls, v: str) -> str: return validate_profile_image_url(v) @@ -267,8 +269,8 @@ def insert_new_user( id: str, name: str, email: str, - profile_image_url: str = "/user.png", - role: str = "pending", + profile_image_url: str = '/user.png', + role: str = 'pending', username: Optional[str] = None, oauth: Optional[dict] = None, db: Optional[Session] = None, @@ -276,16 +278,16 @@ def insert_new_user( with get_db_context(db) as db: user = UserModel( **{ - "id": id, - "email": email, - "name": name, - "role": role, - "profile_image_url": profile_image_url, - "last_active_at": int(time.time()), - "created_at": int(time.time()), - "updated_at": int(time.time()), - "username": username, - "oauth": oauth, + 'id': id, + 'email': email, + 'name': name, + 'role': role, + 'profile_image_url': profile_image_url, + 'last_active_at': int(time.time()), + 'created_at': int(time.time()), + 'updated_at': int(time.time()), + 'username': username, + 'oauth': oauth, } ) result = User(**user.model_dump()) @@ -297,9 +299,7 @@ def insert_new_user( else: return None - def get_user_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[UserModel]: + def get_user_by_id(self, id: str, db: Optional[Session] = None) -> Optional[UserModel]: try: with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() @@ -307,49 +307,32 @@ def get_user_by_id( except Exception: return None - def get_user_by_api_key( - self, api_key: str, db: Optional[Session] = None - ) -> Optional[UserModel]: + def get_user_by_api_key(self, api_key: str, db: Optional[Session] = None) -> Optional[UserModel]: try: with get_db_context(db) as db: - user = ( - db.query(User) - .join(ApiKey, User.id == ApiKey.user_id) - .filter(ApiKey.key == api_key) - .first() - ) + user = db.query(User).join(ApiKey, User.id == ApiKey.user_id).filter(ApiKey.key == api_key).first() return UserModel.model_validate(user) if user else None except Exception: return None - def get_user_by_email( - self, email: str, db: Optional[Session] = None - ) -> Optional[UserModel]: + def get_user_by_email(self, email: str, db: Optional[Session] = None) -> Optional[UserModel]: try: with get_db_context(db) as db: - user = ( - db.query(User) - .filter(func.lower(User.email) == email.lower()) - .first() - ) + user = db.query(User).filter(func.lower(User.email) == email.lower()).first() return UserModel.model_validate(user) if user else None except Exception: return None - def get_user_by_oauth_sub( - self, provider: str, sub: str, db: Optional[Session] = None - ) -> Optional[UserModel]: + def get_user_by_oauth_sub(self, provider: str, sub: str, db: Optional[Session] = None) -> Optional[UserModel]: try: with get_db_context(db) as db: # type: Session dialect_name = db.bind.dialect.name query = db.query(User) - if dialect_name == "sqlite": - query = query.filter(User.oauth.contains({provider: {"sub": sub}})) - elif dialect_name == "postgresql": - query = query.filter( - User.oauth[provider].cast(JSONB)["sub"].astext == sub - ) + if dialect_name == 'sqlite': + query = query.filter(User.oauth.contains({provider: {'sub': sub}})) + elif dialect_name == 'postgresql': + query = query.filter(User.oauth[provider].cast(JSONB)['sub'].astext == sub) user = query.first() return UserModel.model_validate(user) if user else None @@ -365,15 +348,10 @@ def get_user_by_scim_external_id( dialect_name = db.bind.dialect.name query = db.query(User) - if dialect_name == "sqlite": - query = query.filter( - User.scim.contains({provider: {"external_id": external_id}}) - ) - elif dialect_name == "postgresql": - query = query.filter( - User.scim[provider].cast(JSONB)["external_id"].astext - == external_id - ) + if dialect_name == 'sqlite': + query = query.filter(User.scim.contains({provider: {'external_id': external_id}})) + elif dialect_name == 'postgresql': + query = query.filter(User.scim[provider].cast(JSONB)['external_id'].astext == external_id) user = query.first() return UserModel.model_validate(user) if user else None @@ -392,16 +370,16 @@ def get_users( query = db.query(User).options(defer(User.profile_image_url)) if filter: - query_key = filter.get("query") + query_key = filter.get('query') if query_key: query = query.filter( or_( - User.name.ilike(f"%{query_key}%"), - User.email.ilike(f"%{query_key}%"), + User.name.ilike(f'%{query_key}%'), + User.email.ilike(f'%{query_key}%'), ) ) - channel_id = filter.get("channel_id") + channel_id = filter.get('channel_id') if channel_id: query = query.filter( exists( @@ -412,13 +390,13 @@ def get_users( ) ) - user_ids = filter.get("user_ids") - group_ids = filter.get("group_ids") + user_ids = filter.get('user_ids') + group_ids = filter.get('group_ids') if isinstance(user_ids, list) and isinstance(group_ids, list): # If both are empty lists, return no users if not user_ids and not group_ids: - return {"users": [], "total": 0} + return {'users': [], 'total': 0} if user_ids: query = query.filter(User.id.in_(user_ids)) @@ -433,21 +411,21 @@ def get_users( ) ) - roles = filter.get("roles") + roles = filter.get('roles') if roles: - include_roles = [role for role in roles if not role.startswith("!")] - exclude_roles = [role[1:] for role in roles if role.startswith("!")] + include_roles = [role for role in roles if not role.startswith('!')] + exclude_roles = [role[1:] for role in roles if role.startswith('!')] if include_roles: query = query.filter(User.role.in_(include_roles)) if exclude_roles: query = query.filter(~User.role.in_(exclude_roles)) - order_by = filter.get("order_by") - direction = filter.get("direction") + order_by = filter.get('order_by') + direction = filter.get('direction') - if order_by and order_by.startswith("group_id:"): - group_id = order_by.split(":", 1)[1] + if order_by and order_by.startswith('group_id:'): + group_id = order_by.split(':', 1)[1] # Subquery that checks if the user belongs to the group membership_exists = exists( @@ -460,42 +438,42 @@ def get_users( # CASE: user in group โ†’ 1, user not in group โ†’ 0 group_sort = case((membership_exists, 1), else_=0) - if direction == "asc": + if direction == 'asc': query = query.order_by(group_sort.asc(), User.name.asc()) else: query = query.order_by(group_sort.desc(), User.name.asc()) - elif order_by == "name": - if direction == "asc": + elif order_by == 'name': + if direction == 'asc': query = query.order_by(User.name.asc()) else: query = query.order_by(User.name.desc()) - elif order_by == "email": - if direction == "asc": + elif order_by == 'email': + if direction == 'asc': query = query.order_by(User.email.asc()) else: query = query.order_by(User.email.desc()) - elif order_by == "created_at": - if direction == "asc": + elif order_by == 'created_at': + if direction == 'asc': query = query.order_by(User.created_at.asc()) else: query = query.order_by(User.created_at.desc()) - elif order_by == "last_active_at": - if direction == "asc": + elif order_by == 'last_active_at': + if direction == 'asc': query = query.order_by(User.last_active_at.asc()) else: query = query.order_by(User.last_active_at.desc()) - elif order_by == "updated_at": - if direction == "asc": + elif order_by == 'updated_at': + if direction == 'asc': query = query.order_by(User.updated_at.asc()) else: query = query.order_by(User.updated_at.desc()) - elif order_by == "role": - if direction == "asc": + elif order_by == 'role': + if direction == 'asc': query = query.order_by(User.role.asc()) else: query = query.order_by(User.role.desc()) @@ -514,13 +492,11 @@ def get_users( users = query.all() return { - "users": [UserModel.model_validate(user) for user in users], - "total": total, + 'users': [UserModel.model_validate(user) for user in users], + 'total': total, } - def get_users_by_group_id( - self, group_id: str, db: Optional[Session] = None - ) -> list[UserModel]: + def get_users_by_group_id(self, group_id: str, db: Optional[Session] = None) -> list[UserModel]: with get_db_context(db) as db: users = ( db.query(User) @@ -531,16 +507,9 @@ def get_users_by_group_id( ) return [UserModel.model_validate(user) for user in users] - def get_users_by_user_ids( - self, user_ids: list[str], db: Optional[Session] = None - ) -> list[UserStatusModel]: + def get_users_by_user_ids(self, user_ids: list[str], db: Optional[Session] = None) -> list[UserStatusModel]: with get_db_context(db) as db: - users = ( - db.query(User) - .options(defer(User.profile_image_url)) - .filter(User.id.in_(user_ids)) - .all() - ) + users = db.query(User).options(defer(User.profile_image_url)).filter(User.id.in_(user_ids)).all() return [UserModel.model_validate(user) for user in users] def get_num_users(self, db: Optional[Session] = None) -> Optional[int]: @@ -559,9 +528,7 @@ def get_first_user(self, db: Optional[Session] = None) -> UserModel: except Exception: return None - def get_user_webhook_url_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[str]: + def get_user_webhook_url_by_id(self, id: str, db: Optional[Session] = None) -> Optional[str]: try: with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() @@ -569,11 +536,7 @@ def get_user_webhook_url_by_id( if user.settings is None: return None else: - return ( - user.settings.get("ui", {}) - .get("notifications", {}) - .get("webhook_url", None) - ) + return user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None) except Exception: return None @@ -581,14 +544,10 @@ def get_num_users_active_today(self, db: Optional[Session] = None) -> Optional[i with get_db_context(db) as db: current_timestamp = int(datetime.datetime.now().timestamp()) today_midnight_timestamp = current_timestamp - (current_timestamp % 86400) - query = db.query(User).filter( - User.last_active_at > today_midnight_timestamp - ) + query = db.query(User).filter(User.last_active_at > today_midnight_timestamp) return query.count() - def update_user_role_by_id( - self, id: str, role: str, db: Optional[Session] = None - ) -> Optional[UserModel]: + def update_user_role_by_id(self, id: str, role: str, db: Optional[Session] = None) -> Optional[UserModel]: try: with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() @@ -633,9 +592,7 @@ def update_user_profile_image_url_by_id( return None @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL) - def update_last_active_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[UserModel]: + def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> Optional[UserModel]: try: with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() @@ -669,10 +626,10 @@ def update_user_oauth_by_id( oauth = user.oauth or {} # Update or insert provider entry - oauth[provider] = {"sub": sub} + oauth[provider] = {'sub': sub} # Persist updated JSON - db.query(User).filter_by(id=id).update({"oauth": oauth}) + db.query(User).filter_by(id=id).update({'oauth': oauth}) db.commit() return UserModel.model_validate(user) @@ -702,9 +659,9 @@ def update_user_scim_by_id( return None scim = user.scim or {} - scim[provider] = {"external_id": external_id} + scim[provider] = {'external_id': external_id} - db.query(User).filter_by(id=id).update({"scim": scim}) + db.query(User).filter_by(id=id).update({'scim': scim}) db.commit() return UserModel.model_validate(user) @@ -712,9 +669,7 @@ def update_user_scim_by_id( except Exception: return None - def update_user_by_id( - self, id: str, updated: dict, db: Optional[Session] = None - ) -> Optional[UserModel]: + def update_user_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[UserModel]: try: with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() @@ -729,9 +684,7 @@ def update_user_by_id( print(e) return None - def update_user_settings_by_id( - self, id: str, updated: dict, db: Optional[Session] = None - ) -> Optional[UserModel]: + def update_user_settings_by_id(self, id: str, updated: dict, db: Optional[Session] = None) -> Optional[UserModel]: try: with get_db_context(db) as db: user = db.query(User).filter_by(id=id).first() @@ -745,7 +698,7 @@ def update_user_settings_by_id( user_settings.update(updated) - db.query(User).filter_by(id=id).update({"settings": user_settings}) + db.query(User).filter_by(id=id).update({'settings': user_settings}) db.commit() user = db.query(User).filter_by(id=id).first() @@ -772,9 +725,7 @@ def delete_user_by_id(self, id: str, db: Optional[Session] = None) -> bool: except Exception: return False - def get_user_api_key_by_id( - self, id: str, db: Optional[Session] = None - ) -> Optional[str]: + def get_user_api_key_by_id(self, id: str, db: Optional[Session] = None) -> Optional[str]: try: with get_db_context(db) as db: api_key = db.query(ApiKey).filter_by(user_id=id).first() @@ -782,9 +733,7 @@ def get_user_api_key_by_id( except Exception: return None - def update_user_api_key_by_id( - self, id: str, api_key: str, db: Optional[Session] = None - ) -> bool: + def update_user_api_key_by_id(self, id: str, api_key: str, db: Optional[Session] = None) -> bool: try: with get_db_context(db) as db: db.query(ApiKey).filter_by(user_id=id).delete() @@ -792,7 +741,7 @@ def update_user_api_key_by_id( now = int(time.time()) new_api_key = ApiKey( - id=f"key_{id}", + id=f'key_{id}', user_id=id, key=api_key, created_at=now, @@ -815,16 +764,14 @@ def delete_user_api_key_by_id(self, id: str, db: Optional[Session] = None) -> bo except Exception: return False - def get_valid_user_ids( - self, user_ids: list[str], db: Optional[Session] = None - ) -> list[str]: + def get_valid_user_ids(self, user_ids: list[str], db: Optional[Session] = None) -> list[str]: with get_db_context(db) as db: users = db.query(User).filter(User.id.in_(user_ids)).all() return [user.id for user in users] def get_super_admin_user(self, db: Optional[Session] = None) -> Optional[UserModel]: with get_db_context(db) as db: - user = db.query(User).filter_by(role="admin").first() + user = db.query(User).filter_by(role='admin').first() if user: return UserModel.model_validate(user) else: @@ -834,9 +781,7 @@ def get_active_user_count(self, db: Optional[Session] = None) -> int: with get_db_context(db) as db: # Consider user active if last_active_at within the last 3 minutes three_minutes_ago = int(time.time()) - 180 - count = ( - db.query(User).filter(User.last_active_at >= three_minutes_ago).count() - ) + count = db.query(User).filter(User.last_active_at >= three_minutes_ago).count() return count @staticmethod diff --git a/backend/open_webui/retrieval/loaders/datalab_marker.py b/backend/open_webui/retrieval/loaders/datalab_marker.py index 8d14be0a40..dd4a763b70 100644 --- a/backend/open_webui/retrieval/loaders/datalab_marker.py +++ b/backend/open_webui/retrieval/loaders/datalab_marker.py @@ -40,78 +40,76 @@ def __init__( self.output_format = output_format def _get_mime_type(self, filename: str) -> str: - ext = filename.rsplit(".", 1)[-1].lower() + ext = filename.rsplit('.', 1)[-1].lower() mime_map = { - "pdf": "application/pdf", - "xls": "application/vnd.ms-excel", - "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "ods": "application/vnd.oasis.opendocument.spreadsheet", - "doc": "application/msword", - "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "odt": "application/vnd.oasis.opendocument.text", - "ppt": "application/vnd.ms-powerpoint", - "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "odp": "application/vnd.oasis.opendocument.presentation", - "html": "text/html", - "epub": "application/epub+zip", - "png": "image/png", - "jpeg": "image/jpeg", - "jpg": "image/jpeg", - "webp": "image/webp", - "gif": "image/gif", - "tiff": "image/tiff", + 'pdf': 'application/pdf', + 'xls': 'application/vnd.ms-excel', + 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ods': 'application/vnd.oasis.opendocument.spreadsheet', + 'doc': 'application/msword', + 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'odt': 'application/vnd.oasis.opendocument.text', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'odp': 'application/vnd.oasis.opendocument.presentation', + 'html': 'text/html', + 'epub': 'application/epub+zip', + 'png': 'image/png', + 'jpeg': 'image/jpeg', + 'jpg': 'image/jpeg', + 'webp': 'image/webp', + 'gif': 'image/gif', + 'tiff': 'image/tiff', } - return mime_map.get(ext, "application/octet-stream") + return mime_map.get(ext, 'application/octet-stream') def check_marker_request_status(self, request_id: str) -> dict: - url = f"{self.api_base_url}/{request_id}" - headers = {"X-Api-Key": self.api_key} + url = f'{self.api_base_url}/{request_id}' + headers = {'X-Api-Key': self.api_key} try: response = requests.get(url, headers=headers) response.raise_for_status() result = response.json() - log.info(f"Marker API status check for request {request_id}: {result}") + log.info(f'Marker API status check for request {request_id}: {result}') return result except requests.HTTPError as e: - log.error(f"Error checking Marker request status: {e}") + log.error(f'Error checking Marker request status: {e}') raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail=f"Failed to check Marker request: {e}", + detail=f'Failed to check Marker request: {e}', ) except ValueError as e: - log.error(f"Invalid JSON checking Marker request: {e}") - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, detail=f"Invalid JSON: {e}" - ) + log.error(f'Invalid JSON checking Marker request: {e}') + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=f'Invalid JSON: {e}') def load(self) -> List[Document]: filename = os.path.basename(self.file_path) mime_type = self._get_mime_type(filename) - headers = {"X-Api-Key": self.api_key} + headers = {'X-Api-Key': self.api_key} form_data = { - "use_llm": str(self.use_llm).lower(), - "skip_cache": str(self.skip_cache).lower(), - "force_ocr": str(self.force_ocr).lower(), - "paginate": str(self.paginate).lower(), - "strip_existing_ocr": str(self.strip_existing_ocr).lower(), - "disable_image_extraction": str(self.disable_image_extraction).lower(), - "format_lines": str(self.format_lines).lower(), - "output_format": self.output_format, + 'use_llm': str(self.use_llm).lower(), + 'skip_cache': str(self.skip_cache).lower(), + 'force_ocr': str(self.force_ocr).lower(), + 'paginate': str(self.paginate).lower(), + 'strip_existing_ocr': str(self.strip_existing_ocr).lower(), + 'disable_image_extraction': str(self.disable_image_extraction).lower(), + 'format_lines': str(self.format_lines).lower(), + 'output_format': self.output_format, } if self.additional_config and self.additional_config.strip(): - form_data["additional_config"] = self.additional_config + form_data['additional_config'] = self.additional_config log.info( f"Datalab Marker POST request parameters: {{'filename': '{filename}', 'mime_type': '{mime_type}', **{form_data}}}" ) try: - with open(self.file_path, "rb") as f: - files = {"file": (filename, f, mime_type)} + with open(self.file_path, 'rb') as f: + files = {'file': (filename, f, mime_type)} response = requests.post( - f"{self.api_base_url}", + f'{self.api_base_url}', data=form_data, files=files, headers=headers, @@ -119,29 +117,25 @@ def load(self) -> List[Document]: response.raise_for_status() result = response.json() except FileNotFoundError: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}" - ) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f'File not found: {self.file_path}') except requests.HTTPError as e: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Datalab Marker request failed: {e}", + detail=f'Datalab Marker request failed: {e}', ) except ValueError as e: - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, detail=f"Invalid JSON response: {e}" - ) + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=f'Invalid JSON response: {e}') except Exception as e: raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) - if not result.get("success"): + if not result.get('success'): raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Datalab Marker request failed: {result.get('error', 'Unknown error')}", + detail=f'Datalab Marker request failed: {result.get("error", "Unknown error")}', ) - check_url = result.get("request_check_url") - request_id = result.get("request_id") + check_url = result.get('request_check_url') + request_id = result.get('request_id') # Check if this is a direct response (self-hosted) or polling response (DataLab) if check_url: @@ -154,54 +148,45 @@ def load(self) -> List[Document]: poll_result = poll_response.json() except (requests.HTTPError, ValueError) as e: raw_body = poll_response.text - log.error(f"Polling error: {e}, response body: {raw_body}") - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, detail=f"Polling failed: {e}" - ) + log.error(f'Polling error: {e}, response body: {raw_body}') + raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=f'Polling failed: {e}') - status_val = poll_result.get("status") - success_val = poll_result.get("success") + status_val = poll_result.get('status') + success_val = poll_result.get('success') - if status_val == "complete": + if status_val == 'complete': summary = { k: poll_result.get(k) for k in ( - "status", - "output_format", - "success", - "error", - "page_count", - "total_cost", + 'status', + 'output_format', + 'success', + 'error', + 'page_count', + 'total_cost', ) } - log.info( - f"Marker processing completed successfully: {json.dumps(summary, indent=2)}" - ) + log.info(f'Marker processing completed successfully: {json.dumps(summary, indent=2)}') break - if status_val == "failed" or success_val is False: - log.error( - f"Marker poll failed full response: {json.dumps(poll_result, indent=2)}" - ) - error_msg = ( - poll_result.get("error") - or "Marker returned failure without error message" - ) + if status_val == 'failed' or success_val is False: + log.error(f'Marker poll failed full response: {json.dumps(poll_result, indent=2)}') + error_msg = poll_result.get('error') or 'Marker returned failure without error message' raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Marker processing failed: {error_msg}", + detail=f'Marker processing failed: {error_msg}', ) else: raise HTTPException( status.HTTP_504_GATEWAY_TIMEOUT, - detail="Marker processing timed out", + detail='Marker processing timed out', ) - if not poll_result.get("success", False): - error_msg = poll_result.get("error") or "Unknown processing error" + if not poll_result.get('success', False): + error_msg = poll_result.get('error') or 'Unknown processing error' raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Final processing failed: {error_msg}", + detail=f'Final processing failed: {error_msg}', ) # DataLab format - content in format-specific fields @@ -210,69 +195,65 @@ def load(self) -> List[Document]: final_result = poll_result else: # Self-hosted direct response - content in "output" field - if "output" in result: - log.info("Self-hosted Marker returned direct response without polling") - raw_content = result.get("output") + if 'output' in result: + log.info('Self-hosted Marker returned direct response without polling') + raw_content = result.get('output') final_result = result else: - available_fields = ( - list(result.keys()) - if isinstance(result, dict) - else "non-dict response" - ) + available_fields = list(result.keys()) if isinstance(result, dict) else 'non-dict response' raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail=f"Custom Marker endpoint returned success but no 'output' field found. Available fields: {available_fields}. Expected either 'request_check_url' for polling or 'output' field for direct response.", ) - if self.output_format.lower() == "json": + if self.output_format.lower() == 'json': full_text = json.dumps(raw_content, indent=2) - elif self.output_format.lower() in {"markdown", "html"}: + elif self.output_format.lower() in {'markdown', 'html'}: full_text = str(raw_content).strip() else: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Unsupported output format: {self.output_format}", + detail=f'Unsupported output format: {self.output_format}', ) if not full_text: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="Marker returned empty content", + detail='Marker returned empty content', ) - marker_output_dir = os.path.join("/app/backend/data/uploads", "marker_output") + marker_output_dir = os.path.join('/app/backend/data/uploads', 'marker_output') os.makedirs(marker_output_dir, exist_ok=True) - file_ext_map = {"markdown": "md", "json": "json", "html": "html"} - file_ext = file_ext_map.get(self.output_format.lower(), "txt") - output_filename = f"{os.path.splitext(filename)[0]}.{file_ext}" + file_ext_map = {'markdown': 'md', 'json': 'json', 'html': 'html'} + file_ext = file_ext_map.get(self.output_format.lower(), 'txt') + output_filename = f'{os.path.splitext(filename)[0]}.{file_ext}' output_path = os.path.join(marker_output_dir, output_filename) try: - with open(output_path, "w", encoding="utf-8") as f: + with open(output_path, 'w', encoding='utf-8') as f: f.write(full_text) - log.info(f"Saved Marker output to: {output_path}") + log.info(f'Saved Marker output to: {output_path}') except Exception as e: - log.warning(f"Failed to write marker output to disk: {e}") + log.warning(f'Failed to write marker output to disk: {e}') metadata = { - "source": filename, - "output_format": final_result.get("output_format", self.output_format), - "page_count": final_result.get("page_count", 0), - "processed_with_llm": self.use_llm, - "request_id": request_id or "", + 'source': filename, + 'output_format': final_result.get('output_format', self.output_format), + 'page_count': final_result.get('page_count', 0), + 'processed_with_llm': self.use_llm, + 'request_id': request_id or '', } - images = final_result.get("images", {}) + images = final_result.get('images', {}) if images: - metadata["image_count"] = len(images) - metadata["images"] = json.dumps(list(images.keys())) + metadata['image_count'] = len(images) + metadata['images'] = json.dumps(list(images.keys())) for k, v in metadata.items(): if isinstance(v, (dict, list)): metadata[k] = json.dumps(v) elif v is None: - metadata[k] = "" + metadata[k] = '' return [Document(page_content=full_text, metadata=metadata)] diff --git a/backend/open_webui/retrieval/loaders/external_document.py b/backend/open_webui/retrieval/loaders/external_document.py index e1371be288..77b1abfcd8 100644 --- a/backend/open_webui/retrieval/loaders/external_document.py +++ b/backend/open_webui/retrieval/loaders/external_document.py @@ -29,43 +29,42 @@ def __init__( self.user = user def load(self) -> List[Document]: - with open(self.file_path, "rb") as f: + with open(self.file_path, 'rb') as f: data = f.read() headers = {} if self.mime_type is not None: - headers["Content-Type"] = self.mime_type + headers['Content-Type'] = self.mime_type if self.api_key is not None: - headers["Authorization"] = f"Bearer {self.api_key}" + headers['Authorization'] = f'Bearer {self.api_key}' try: - headers["X-Filename"] = quote(os.path.basename(self.file_path)) - except: + headers['X-Filename'] = quote(os.path.basename(self.file_path)) + except Exception: pass if self.user is not None: headers = include_user_info_headers(headers, self.user) url = self.url - if url.endswith("/"): + if url.endswith('/'): url = url[:-1] try: - response = requests.put(f"{url}/process", data=data, headers=headers) + response = requests.put(f'{url}/process', data=data, headers=headers) except Exception as e: - log.error(f"Error connecting to endpoint: {e}") - raise Exception(f"Error connecting to endpoint: {e}") + log.error(f'Error connecting to endpoint: {e}') + raise Exception(f'Error connecting to endpoint: {e}') if response.ok: - response_data = response.json() if response_data: if isinstance(response_data, dict): return [ Document( - page_content=response_data.get("page_content"), - metadata=response_data.get("metadata"), + page_content=response_data.get('page_content'), + metadata=response_data.get('metadata'), ) ] elif isinstance(response_data, list): @@ -73,17 +72,15 @@ def load(self) -> List[Document]: for document in response_data: documents.append( Document( - page_content=document.get("page_content"), - metadata=document.get("metadata"), + page_content=document.get('page_content'), + metadata=document.get('metadata'), ) ) return documents else: - raise Exception("Error loading document: Unable to parse content") + raise Exception('Error loading document: Unable to parse content') else: - raise Exception("Error loading document: No content returned") + raise Exception('Error loading document: No content returned') else: - raise Exception( - f"Error loading document: {response.status_code} {response.text}" - ) + raise Exception(f'Error loading document: {response.status_code} {response.text}') diff --git a/backend/open_webui/retrieval/loaders/external_web.py b/backend/open_webui/retrieval/loaders/external_web.py index 39644caddb..64248427b3 100644 --- a/backend/open_webui/retrieval/loaders/external_web.py +++ b/backend/open_webui/retrieval/loaders/external_web.py @@ -30,22 +30,22 @@ def lazy_load(self) -> Iterator[Document]: response = requests.post( self.external_url, headers={ - "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) External Web Loader", - "Authorization": f"Bearer {self.external_api_key}", + 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) External Web Loader', + 'Authorization': f'Bearer {self.external_api_key}', }, json={ - "urls": urls, + 'urls': urls, }, ) response.raise_for_status() results = response.json() for result in results: yield Document( - page_content=result.get("page_content", ""), - metadata=result.get("metadata", {}), + page_content=result.get('page_content', ''), + metadata=result.get('metadata', {}), ) except Exception as e: if self.continue_on_failure: - log.error(f"Error extracting content from batch {urls}: {e}") + log.error(f'Error extracting content from batch {urls}: {e}') else: raise e diff --git a/backend/open_webui/retrieval/loaders/main.py b/backend/open_webui/retrieval/loaders/main.py index d4cc4391aa..57867d78f5 100644 --- a/backend/open_webui/retrieval/loaders/main.py +++ b/backend/open_webui/retrieval/loaders/main.py @@ -13,12 +13,6 @@ OutlookMessageLoader, PyPDFLoader, TextLoader, - UnstructuredEPubLoader, - UnstructuredExcelLoader, - UnstructuredODTLoader, - UnstructuredPowerPointLoader, - UnstructuredRSTLoader, - UnstructuredXMLLoader, YoutubeLoader, ) from langchain_core.documents import Document @@ -36,62 +30,110 @@ log = logging.getLogger(__name__) known_source_ext = [ - "go", - "py", - "java", - "sh", - "bat", - "ps1", - "cmd", - "js", - "ts", - "css", - "cpp", - "hpp", - "h", - "c", - "cs", - "sql", - "log", - "ini", - "pl", - "pm", - "r", - "dart", - "dockerfile", - "env", - "php", - "hs", - "hsc", - "lua", - "nginxconf", - "conf", - "m", - "mm", - "plsql", - "perl", - "rb", - "rs", - "db2", - "scala", - "bash", - "swift", - "vue", - "svelte", - "ex", - "exs", - "erl", - "tsx", - "jsx", - "hs", - "lhs", - "json", - "yaml", - "yml", - "toml", + 'go', + 'py', + 'java', + 'sh', + 'bat', + 'ps1', + 'cmd', + 'js', + 'ts', + 'css', + 'cpp', + 'hpp', + 'h', + 'c', + 'cs', + 'sql', + 'log', + 'ini', + 'pl', + 'pm', + 'r', + 'dart', + 'dockerfile', + 'env', + 'php', + 'hs', + 'hsc', + 'lua', + 'nginxconf', + 'conf', + 'm', + 'mm', + 'plsql', + 'perl', + 'rb', + 'rs', + 'db2', + 'scala', + 'bash', + 'swift', + 'vue', + 'svelte', + 'ex', + 'exs', + 'erl', + 'tsx', + 'jsx', + 'hs', + 'lhs', + 'json', + 'yaml', + 'yml', + 'toml', ] +class ExcelLoader: + """Fallback Excel loader using pandas when unstructured is not installed.""" + + def __init__(self, file_path): + self.file_path = file_path + + def load(self) -> list[Document]: + import pandas as pd + + text_parts = [] + xls = pd.ExcelFile(self.file_path) + for sheet_name in xls.sheet_names: + df = pd.read_excel(xls, sheet_name=sheet_name) + text_parts.append(f'Sheet: {sheet_name}\n{df.to_string(index=False)}') + return [ + Document( + page_content='\n\n'.join(text_parts), + metadata={'source': self.file_path}, + ) + ] + + +class PptxLoader: + """Fallback PowerPoint loader using python-pptx when unstructured is not installed.""" + + def __init__(self, file_path): + self.file_path = file_path + + def load(self) -> list[Document]: + from pptx import Presentation + + prs = Presentation(self.file_path) + text_parts = [] + for i, slide in enumerate(prs.slides, 1): + slide_texts = [] + for shape in slide.shapes: + if shape.has_text_frame: + slide_texts.append(shape.text_frame.text) + if slide_texts: + text_parts.append(f'Slide {i}:\n' + '\n'.join(slide_texts)) + return [ + Document( + page_content='\n\n'.join(text_parts), + metadata={'source': self.file_path}, + ) + ] + + class TikaLoader: def __init__(self, url, file_path, mime_type=None, extract_images=None): self.url = url @@ -101,41 +143,41 @@ def __init__(self, url, file_path, mime_type=None, extract_images=None): self.extract_images = extract_images def load(self) -> list[Document]: - with open(self.file_path, "rb") as f: + with open(self.file_path, 'rb') as f: data = f.read() if self.mime_type is not None: - headers = {"Content-Type": self.mime_type} + headers = {'Content-Type': self.mime_type} else: headers = {} if self.extract_images == True: - headers["X-Tika-PDFextractInlineImages"] = "true" + headers['X-Tika-PDFextractInlineImages'] = 'true' endpoint = self.url - if not endpoint.endswith("/"): - endpoint += "/" - endpoint += "tika/text" + if not endpoint.endswith('/'): + endpoint += '/' + endpoint += 'tika/text' r = requests.put(endpoint, data=data, headers=headers, verify=REQUESTS_VERIFY) if r.ok: raw_metadata = r.json() - text = raw_metadata.get("X-TIKA:content", "").strip() + text = raw_metadata.get('X-TIKA:content', '').strip() - if "Content-Type" in raw_metadata: - headers["Content-Type"] = raw_metadata["Content-Type"] + if 'Content-Type' in raw_metadata: + headers['Content-Type'] = raw_metadata['Content-Type'] - log.debug("Tika extracted text: %s", text) + log.debug('Tika extracted text: %s', text) return [Document(page_content=text, metadata=headers)] else: - raise Exception(f"Error calling Tika: {r.reason}") + raise Exception(f'Error calling Tika: {r.reason}') class DoclingLoader: def __init__(self, url, api_key=None, file_path=None, mime_type=None, params=None): - self.url = url.rstrip("/") + self.url = url.rstrip('/') self.api_key = api_key self.file_path = file_path self.mime_type = mime_type @@ -143,199 +185,183 @@ def __init__(self, url, api_key=None, file_path=None, mime_type=None, params=Non self.params = params or {} def load(self) -> list[Document]: - with open(self.file_path, "rb") as f: + with open(self.file_path, 'rb') as f: headers = {} if self.api_key: - headers["X-Api-Key"] = f"{self.api_key}" + headers['X-Api-Key'] = f'{self.api_key}' r = requests.post( - f"{self.url}/v1/convert/file", + f'{self.url}/v1/convert/file', files={ - "files": ( + 'files': ( self.file_path, f, - self.mime_type or "application/octet-stream", + self.mime_type or 'application/octet-stream', ) }, data={ - "image_export_mode": "placeholder", + 'image_export_mode': 'placeholder', **self.params, }, headers=headers, ) if r.ok: result = r.json() - document_data = result.get("document", {}) - text = document_data.get("md_content", "") + document_data = result.get('document', {}) + text = document_data.get('md_content', '') - metadata = {"Content-Type": self.mime_type} if self.mime_type else {} + metadata = {'Content-Type': self.mime_type} if self.mime_type else {} - log.debug("Docling extracted text: %s", text) + log.debug('Docling extracted text: %s', text) return [Document(page_content=text, metadata=metadata)] else: - error_msg = f"Error calling Docling API: {r.reason}" + error_msg = f'Error calling Docling API: {r.reason}' if r.text: try: error_data = r.json() - if "detail" in error_data: - error_msg += f" - {error_data['detail']}" + if 'detail' in error_data: + error_msg += f' - {error_data["detail"]}' except Exception: - error_msg += f" - {r.text}" - raise Exception(f"Error calling Docling: {error_msg}") + error_msg += f' - {r.text}' + raise Exception(f'Error calling Docling: {error_msg}') class Loader: - def __init__(self, engine: str = "", **kwargs): + def __init__(self, engine: str = '', **kwargs): self.engine = engine - self.user = kwargs.get("user", None) + self.user = kwargs.get('user', None) self.kwargs = kwargs - def load( - self, filename: str, file_content_type: str, file_path: str - ) -> list[Document]: + def load(self, filename: str, file_content_type: str, file_path: str) -> list[Document]: loader = self._get_loader(filename, file_content_type, file_path) docs = loader.load() - return [ - Document( - page_content=ftfy.fix_text(doc.page_content), metadata=doc.metadata - ) - for doc in docs - ] + return [Document(page_content=ftfy.fix_text(doc.page_content), metadata=doc.metadata) for doc in docs] def _is_text_file(self, file_ext: str, file_content_type: str) -> bool: return file_ext in known_source_ext or ( file_content_type - and file_content_type.find("text/") >= 0 + and file_content_type.find('text/') >= 0 # Avoid text/html files being detected as text - and not file_content_type.find("html") >= 0 + and not file_content_type.find('html') >= 0 ) def _get_loader(self, filename: str, file_content_type: str, file_path: str): - file_ext = filename.split(".")[-1].lower() + file_ext = filename.split('.')[-1].lower() if ( - self.engine == "external" - and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL") - and self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY") + self.engine == 'external' + and self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_URL') + and self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY') ): loader = ExternalDocumentLoader( file_path=file_path, - url=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_URL"), - api_key=self.kwargs.get("EXTERNAL_DOCUMENT_LOADER_API_KEY"), + url=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_URL'), + api_key=self.kwargs.get('EXTERNAL_DOCUMENT_LOADER_API_KEY'), mime_type=file_content_type, user=self.user, ) - elif self.engine == "tika" and self.kwargs.get("TIKA_SERVER_URL"): + elif self.engine == 'tika' and self.kwargs.get('TIKA_SERVER_URL'): if self._is_text_file(file_ext, file_content_type): loader = TextLoader(file_path, autodetect_encoding=True) else: loader = TikaLoader( - url=self.kwargs.get("TIKA_SERVER_URL"), + url=self.kwargs.get('TIKA_SERVER_URL'), file_path=file_path, - extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"), + extract_images=self.kwargs.get('PDF_EXTRACT_IMAGES'), ) elif ( - self.engine == "datalab_marker" - and self.kwargs.get("DATALAB_MARKER_API_KEY") + self.engine == 'datalab_marker' + and self.kwargs.get('DATALAB_MARKER_API_KEY') and file_ext in [ - "pdf", - "xls", - "xlsx", - "ods", - "doc", - "docx", - "odt", - "ppt", - "pptx", - "odp", - "html", - "epub", - "png", - "jpeg", - "jpg", - "webp", - "gif", - "tiff", + 'pdf', + 'xls', + 'xlsx', + 'ods', + 'doc', + 'docx', + 'odt', + 'ppt', + 'pptx', + 'odp', + 'html', + 'epub', + 'png', + 'jpeg', + 'jpg', + 'webp', + 'gif', + 'tiff', ] ): - api_base_url = self.kwargs.get("DATALAB_MARKER_API_BASE_URL", "") - if not api_base_url or api_base_url.strip() == "": - api_base_url = "https://www.datalab.to/api/v1/marker" # https://github.com/open-webui/open-webui/pull/16867#issuecomment-3218424349 + api_base_url = self.kwargs.get('DATALAB_MARKER_API_BASE_URL', '') + if not api_base_url or api_base_url.strip() == '': + api_base_url = 'https://www.datalab.to/api/v1/marker' # https://github.com/open-webui/open-webui/pull/16867#issuecomment-3218424349 loader = DatalabMarkerLoader( file_path=file_path, - api_key=self.kwargs["DATALAB_MARKER_API_KEY"], + api_key=self.kwargs['DATALAB_MARKER_API_KEY'], api_base_url=api_base_url, - additional_config=self.kwargs.get("DATALAB_MARKER_ADDITIONAL_CONFIG"), - use_llm=self.kwargs.get("DATALAB_MARKER_USE_LLM", False), - skip_cache=self.kwargs.get("DATALAB_MARKER_SKIP_CACHE", False), - force_ocr=self.kwargs.get("DATALAB_MARKER_FORCE_OCR", False), - paginate=self.kwargs.get("DATALAB_MARKER_PAGINATE", False), - strip_existing_ocr=self.kwargs.get( - "DATALAB_MARKER_STRIP_EXISTING_OCR", False - ), - disable_image_extraction=self.kwargs.get( - "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION", False - ), - format_lines=self.kwargs.get("DATALAB_MARKER_FORMAT_LINES", False), - output_format=self.kwargs.get( - "DATALAB_MARKER_OUTPUT_FORMAT", "markdown" - ), + additional_config=self.kwargs.get('DATALAB_MARKER_ADDITIONAL_CONFIG'), + use_llm=self.kwargs.get('DATALAB_MARKER_USE_LLM', False), + skip_cache=self.kwargs.get('DATALAB_MARKER_SKIP_CACHE', False), + force_ocr=self.kwargs.get('DATALAB_MARKER_FORCE_OCR', False), + paginate=self.kwargs.get('DATALAB_MARKER_PAGINATE', False), + strip_existing_ocr=self.kwargs.get('DATALAB_MARKER_STRIP_EXISTING_OCR', False), + disable_image_extraction=self.kwargs.get('DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION', False), + format_lines=self.kwargs.get('DATALAB_MARKER_FORMAT_LINES', False), + output_format=self.kwargs.get('DATALAB_MARKER_OUTPUT_FORMAT', 'markdown'), ) - elif self.engine == "docling" and self.kwargs.get("DOCLING_SERVER_URL"): + elif self.engine == 'docling' and self.kwargs.get('DOCLING_SERVER_URL'): if self._is_text_file(file_ext, file_content_type): loader = TextLoader(file_path, autodetect_encoding=True) else: # Build params for DoclingLoader - params = self.kwargs.get("DOCLING_PARAMS", {}) + params = self.kwargs.get('DOCLING_PARAMS', {}) if not isinstance(params, dict): try: params = json.loads(params) except json.JSONDecodeError: - log.error("Invalid DOCLING_PARAMS format, expected JSON object") + log.error('Invalid DOCLING_PARAMS format, expected JSON object') params = {} loader = DoclingLoader( - url=self.kwargs.get("DOCLING_SERVER_URL"), - api_key=self.kwargs.get("DOCLING_API_KEY", None), + url=self.kwargs.get('DOCLING_SERVER_URL'), + api_key=self.kwargs.get('DOCLING_API_KEY', None), file_path=file_path, mime_type=file_content_type, params=params, ) elif ( - self.engine == "document_intelligence" - and self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT") != "" + self.engine == 'document_intelligence' + and self.kwargs.get('DOCUMENT_INTELLIGENCE_ENDPOINT') != '' and ( - file_ext in ["pdf", "docx", "ppt", "pptx"] + file_ext in ['pdf', 'docx', 'ppt', 'pptx'] or file_content_type in [ - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-powerpoint", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ] ) ): - if self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY") != "": + if self.kwargs.get('DOCUMENT_INTELLIGENCE_KEY') != '': loader = AzureAIDocumentIntelligenceLoader( file_path=file_path, - api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"), - api_key=self.kwargs.get("DOCUMENT_INTELLIGENCE_KEY"), - api_model=self.kwargs.get("DOCUMENT_INTELLIGENCE_MODEL"), + api_endpoint=self.kwargs.get('DOCUMENT_INTELLIGENCE_ENDPOINT'), + api_key=self.kwargs.get('DOCUMENT_INTELLIGENCE_KEY'), + api_model=self.kwargs.get('DOCUMENT_INTELLIGENCE_MODEL'), ) else: loader = AzureAIDocumentIntelligenceLoader( file_path=file_path, - api_endpoint=self.kwargs.get("DOCUMENT_INTELLIGENCE_ENDPOINT"), + api_endpoint=self.kwargs.get('DOCUMENT_INTELLIGENCE_ENDPOINT'), azure_credential=DefaultAzureCredential(), - api_model=self.kwargs.get("DOCUMENT_INTELLIGENCE_MODEL"), + api_model=self.kwargs.get('DOCUMENT_INTELLIGENCE_MODEL'), ) - elif self.engine == "mineru" and file_ext in [ - "pdf" - ]: # MinerU currently only supports PDF - - mineru_timeout = self.kwargs.get("MINERU_API_TIMEOUT", 300) + elif self.engine == 'mineru' and file_ext in ['pdf']: # MinerU currently only supports PDF + mineru_timeout = self.kwargs.get('MINERU_API_TIMEOUT', 300) if mineru_timeout: try: mineru_timeout = int(mineru_timeout) @@ -344,62 +370,116 @@ def _get_loader(self, filename: str, file_content_type: str, file_path: str): loader = MinerULoader( file_path=file_path, - api_mode=self.kwargs.get("MINERU_API_MODE", "local"), - api_url=self.kwargs.get("MINERU_API_URL", "http://localhost:8000"), - api_key=self.kwargs.get("MINERU_API_KEY", ""), - params=self.kwargs.get("MINERU_PARAMS", {}), + api_mode=self.kwargs.get('MINERU_API_MODE', 'local'), + api_url=self.kwargs.get('MINERU_API_URL', 'http://localhost:8000'), + api_key=self.kwargs.get('MINERU_API_KEY', ''), + params=self.kwargs.get('MINERU_PARAMS', {}), timeout=mineru_timeout, ) elif ( - self.engine == "mistral_ocr" - and self.kwargs.get("MISTRAL_OCR_API_KEY") != "" - and file_ext - in ["pdf"] # Mistral OCR currently only supports PDF and images + self.engine == 'mistral_ocr' + and self.kwargs.get('MISTRAL_OCR_API_KEY') != '' + and file_ext in ['pdf'] # Mistral OCR currently only supports PDF and images ): loader = MistralLoader( - base_url=self.kwargs.get("MISTRAL_OCR_API_BASE_URL"), - api_key=self.kwargs.get("MISTRAL_OCR_API_KEY"), + base_url=self.kwargs.get('MISTRAL_OCR_API_BASE_URL'), + api_key=self.kwargs.get('MISTRAL_OCR_API_KEY'), file_path=file_path, ) else: - if file_ext == "pdf": + if file_ext == 'pdf': loader = PyPDFLoader( file_path, - extract_images=self.kwargs.get("PDF_EXTRACT_IMAGES"), - mode=self.kwargs.get("PDF_LOADER_MODE", "page"), + extract_images=self.kwargs.get('PDF_EXTRACT_IMAGES'), + mode=self.kwargs.get('PDF_LOADER_MODE', 'page'), ) - elif file_ext == "csv": + elif file_ext == 'csv': loader = CSVLoader(file_path, autodetect_encoding=True) - elif file_ext == "rst": - loader = UnstructuredRSTLoader(file_path, mode="elements") - elif file_ext == "xml": - loader = UnstructuredXMLLoader(file_path) - elif file_ext in ["htm", "html"]: - loader = BSHTMLLoader(file_path, open_encoding="unicode_escape") - elif file_ext == "md": + elif file_ext == 'rst': + try: + from langchain_community.document_loaders import UnstructuredRSTLoader + + loader = UnstructuredRSTLoader(file_path, mode='elements') + except ImportError: + log.warning( + "The 'unstructured' package is not installed. " + 'Falling back to plain text loading for .rst file. ' + 'Install it with: pip install unstructured' + ) + loader = TextLoader(file_path, autodetect_encoding=True) + elif file_ext == 'xml': + try: + from langchain_community.document_loaders import UnstructuredXMLLoader + + loader = UnstructuredXMLLoader(file_path) + except ImportError: + log.warning( + "The 'unstructured' package is not installed. " + 'Falling back to plain text loading for .xml file. ' + 'Install it with: pip install unstructured' + ) + loader = TextLoader(file_path, autodetect_encoding=True) + elif file_ext in ['htm', 'html']: + loader = BSHTMLLoader(file_path, open_encoding='unicode_escape') + elif file_ext == 'md': loader = TextLoader(file_path, autodetect_encoding=True) - elif file_content_type == "application/epub+zip": - loader = UnstructuredEPubLoader(file_path) + elif file_content_type == 'application/epub+zip': + try: + from langchain_community.document_loaders import UnstructuredEPubLoader + + loader = UnstructuredEPubLoader(file_path) + except ImportError: + raise ValueError( + "Processing .epub files requires the 'unstructured' package. " + 'Install it with: pip install unstructured' + ) elif ( - file_content_type - == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - or file_ext == "docx" + file_content_type == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + or file_ext == 'docx' ): loader = Docx2txtLoader(file_path) elif file_content_type in [ - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ] or file_ext in ["xls", "xlsx"]: - loader = UnstructuredExcelLoader(file_path) + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ] or file_ext in ['xls', 'xlsx']: + try: + from langchain_community.document_loaders import UnstructuredExcelLoader + + loader = UnstructuredExcelLoader(file_path) + except ImportError: + log.warning( + "The 'unstructured' package is not installed. " + 'Falling back to pandas for Excel file loading. ' + 'Install unstructured for better results: pip install unstructured' + ) + loader = ExcelLoader(file_path) elif file_content_type in [ - "application/vnd.ms-powerpoint", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ] or file_ext in ["ppt", "pptx"]: - loader = UnstructuredPowerPointLoader(file_path) - elif file_ext == "msg": + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ] or file_ext in ['ppt', 'pptx']: + try: + from langchain_community.document_loaders import UnstructuredPowerPointLoader + + loader = UnstructuredPowerPointLoader(file_path) + except ImportError: + log.warning( + "The 'unstructured' package is not installed. " + 'Falling back to python-pptx for PowerPoint file loading. ' + 'Install unstructured for better results: pip install unstructured' + ) + loader = PptxLoader(file_path) + elif file_ext == 'msg': loader = OutlookMessageLoader(file_path) - elif file_ext == "odt": - loader = UnstructuredODTLoader(file_path) + elif file_ext == 'odt': + try: + from langchain_community.document_loaders import UnstructuredODTLoader + + loader = UnstructuredODTLoader(file_path) + except ImportError: + raise ValueError( + "Processing .odt files requires the 'unstructured' package. " + 'Install it with: pip install unstructured' + ) elif self._is_text_file(file_ext, file_content_type): loader = TextLoader(file_path, autodetect_encoding=True) else: diff --git a/backend/open_webui/retrieval/loaders/mineru.py b/backend/open_webui/retrieval/loaders/mineru.py index 617be8e87a..1f0848a613 100644 --- a/backend/open_webui/retrieval/loaders/mineru.py +++ b/backend/open_webui/retrieval/loaders/mineru.py @@ -22,37 +22,35 @@ class MinerULoader: def __init__( self, file_path: str, - api_mode: str = "local", - api_url: str = "http://localhost:8000", - api_key: str = "", + api_mode: str = 'local', + api_url: str = 'http://localhost:8000', + api_key: str = '', params: dict = None, timeout: Optional[int] = 300, ): self.file_path = file_path self.api_mode = api_mode.lower() - self.api_url = api_url.rstrip("/") + self.api_url = api_url.rstrip('/') self.api_key = api_key self.timeout = timeout # Parse params dict with defaults self.params = params or {} - self.enable_ocr = params.get("enable_ocr", False) - self.enable_formula = params.get("enable_formula", True) - self.enable_table = params.get("enable_table", True) - self.language = params.get("language", "en") - self.model_version = params.get("model_version", "pipeline") + self.enable_ocr = params.get('enable_ocr', False) + self.enable_formula = params.get('enable_formula', True) + self.enable_table = params.get('enable_table', True) + self.language = params.get('language', 'en') + self.model_version = params.get('model_version', 'pipeline') - self.page_ranges = self.params.pop("page_ranges", "") + self.page_ranges = self.params.pop('page_ranges', '') # Validate API mode - if self.api_mode not in ["local", "cloud"]: - raise ValueError( - f"Invalid API mode: {self.api_mode}. Must be 'local' or 'cloud'" - ) + if self.api_mode not in ['local', 'cloud']: + raise ValueError(f"Invalid API mode: {self.api_mode}. Must be 'local' or 'cloud'") # Validate Cloud API requirements - if self.api_mode == "cloud" and not self.api_key: - raise ValueError("API key is required for Cloud API mode") + if self.api_mode == 'cloud' and not self.api_key: + raise ValueError('API key is required for Cloud API mode') def load(self) -> List[Document]: """ @@ -60,12 +58,12 @@ def load(self) -> List[Document]: Routes to Cloud or Local API based on api_mode. """ try: - if self.api_mode == "cloud": + if self.api_mode == 'cloud': return self._load_cloud_api() else: return self._load_local_api() except Exception as e: - log.error(f"Error loading document with MinerU: {e}") + log.error(f'Error loading document with MinerU: {e}') raise def _load_local_api(self) -> List[Document]: @@ -73,14 +71,14 @@ def _load_local_api(self) -> List[Document]: Load document using Local API (synchronous). Posts file to /file_parse endpoint and gets immediate response. """ - log.info(f"Using MinerU Local API at {self.api_url}") + log.info(f'Using MinerU Local API at {self.api_url}') filename = os.path.basename(self.file_path) # Build form data for Local API form_data = { **self.params, - "return_md": "true", + 'return_md': 'true', } # Page ranges (Local API uses start_page_id and end_page_id) @@ -89,18 +87,18 @@ def _load_local_api(self) -> List[Document]: # Full page range parsing would require parsing the string log.warning( f"Page ranges '{self.page_ranges}' specified but Local API uses different format. " - "Consider using start_page_id/end_page_id parameters if needed." + 'Consider using start_page_id/end_page_id parameters if needed.' ) try: - with open(self.file_path, "rb") as f: - files = {"files": (filename, f, "application/octet-stream")} + with open(self.file_path, 'rb') as f: + files = {'files': (filename, f, 'application/octet-stream')} - log.info(f"Sending file to MinerU Local API: {filename}") - log.debug(f"Local API parameters: {form_data}") + log.info(f'Sending file to MinerU Local API: {filename}') + log.debug(f'Local API parameters: {form_data}') response = requests.post( - f"{self.api_url}/file_parse", + f'{self.api_url}/file_parse', data=form_data, files=files, timeout=self.timeout, @@ -108,27 +106,25 @@ def _load_local_api(self) -> List[Document]: response.raise_for_status() except FileNotFoundError: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}" - ) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f'File not found: {self.file_path}') except requests.Timeout: raise HTTPException( status.HTTP_504_GATEWAY_TIMEOUT, - detail="MinerU Local API request timed out", + detail='MinerU Local API request timed out', ) except requests.HTTPError as e: - error_detail = f"MinerU Local API request failed: {e}" + error_detail = f'MinerU Local API request failed: {e}' if e.response is not None: try: error_data = e.response.json() - error_detail += f" - {error_data}" - except: - error_detail += f" - {e.response.text}" + error_detail += f' - {error_data}' + except Exception: + error_detail += f' - {e.response.text}' raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail) except Exception as e: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error calling MinerU Local API: {str(e)}", + detail=f'Error calling MinerU Local API: {str(e)}', ) # Parse response @@ -137,41 +133,41 @@ def _load_local_api(self) -> List[Document]: except ValueError as e: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail=f"Invalid JSON response from MinerU Local API: {e}", + detail=f'Invalid JSON response from MinerU Local API: {e}', ) # Extract markdown content from response - if "results" not in result: + if 'results' not in result: raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail="MinerU Local API response missing 'results' field", ) - results = result["results"] + results = result['results'] if not results: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="MinerU returned empty results", + detail='MinerU returned empty results', ) # Get the first (and typically only) result file_result = list(results.values())[0] - markdown_content = file_result.get("md_content", "") + markdown_content = file_result.get('md_content', '') if not markdown_content: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="MinerU returned empty markdown content", + detail='MinerU returned empty markdown content', ) - log.info(f"Successfully parsed document with MinerU Local API: {filename}") + log.info(f'Successfully parsed document with MinerU Local API: {filename}') # Create metadata metadata = { - "source": filename, - "api_mode": "local", - "backend": result.get("backend", "unknown"), - "version": result.get("version", "unknown"), + 'source': filename, + 'api_mode': 'local', + 'backend': result.get('backend', 'unknown'), + 'version': result.get('version', 'unknown'), } return [Document(page_content=markdown_content, metadata=metadata)] @@ -181,7 +177,7 @@ def _load_cloud_api(self) -> List[Document]: Load document using Cloud API (asynchronous). Uses batch upload endpoint to avoid need for public file URLs. """ - log.info(f"Using MinerU Cloud API at {self.api_url}") + log.info(f'Using MinerU Cloud API at {self.api_url}') filename = os.path.basename(self.file_path) @@ -195,17 +191,15 @@ def _load_cloud_api(self) -> List[Document]: result = self._poll_batch_status(batch_id, filename) # Step 4: Download and extract markdown from ZIP - markdown_content = self._download_and_extract_zip( - result["full_zip_url"], filename - ) + markdown_content = self._download_and_extract_zip(result['full_zip_url'], filename) - log.info(f"Successfully parsed document with MinerU Cloud API: {filename}") + log.info(f'Successfully parsed document with MinerU Cloud API: {filename}') # Create metadata metadata = { - "source": filename, - "api_mode": "cloud", - "batch_id": batch_id, + 'source': filename, + 'api_mode': 'cloud', + 'batch_id': batch_id, } return [Document(page_content=markdown_content, metadata=metadata)] @@ -216,49 +210,49 @@ def _request_upload_url(self, filename: str) -> tuple: Returns (batch_id, upload_url). """ headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', } # Build request body request_body = { **self.params, - "files": [ + 'files': [ { - "name": filename, - "is_ocr": self.enable_ocr, + 'name': filename, + 'is_ocr': self.enable_ocr, } ], } # Add page ranges if specified if self.page_ranges: - request_body["files"][0]["page_ranges"] = self.page_ranges + request_body['files'][0]['page_ranges'] = self.page_ranges - log.info(f"Requesting upload URL for: {filename}") - log.debug(f"Cloud API request body: {request_body}") + log.info(f'Requesting upload URL for: {filename}') + log.debug(f'Cloud API request body: {request_body}') try: response = requests.post( - f"{self.api_url}/file-urls/batch", + f'{self.api_url}/file-urls/batch', headers=headers, json=request_body, timeout=30, ) response.raise_for_status() except requests.HTTPError as e: - error_detail = f"Failed to request upload URL: {e}" + error_detail = f'Failed to request upload URL: {e}' if e.response is not None: try: error_data = e.response.json() - error_detail += f" - {error_data.get('msg', error_data)}" - except: - error_detail += f" - {e.response.text}" + error_detail += f' - {error_data.get("msg", error_data)}' + except Exception: + error_detail += f' - {e.response.text}' raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail) except Exception as e: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error requesting upload URL: {str(e)}", + detail=f'Error requesting upload URL: {str(e)}', ) try: @@ -266,28 +260,28 @@ def _request_upload_url(self, filename: str) -> tuple: except ValueError as e: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail=f"Invalid JSON response: {e}", + detail=f'Invalid JSON response: {e}', ) # Check for API error response - if result.get("code") != 0: + if result.get('code') != 0: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"MinerU Cloud API error: {result.get('msg', 'Unknown error')}", + detail=f'MinerU Cloud API error: {result.get("msg", "Unknown error")}', ) - data = result.get("data", {}) - batch_id = data.get("batch_id") - file_urls = data.get("file_urls", []) + data = result.get('data', {}) + batch_id = data.get('batch_id') + file_urls = data.get('file_urls', []) if not batch_id or not file_urls: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail="MinerU Cloud API response missing batch_id or file_urls", + detail='MinerU Cloud API response missing batch_id or file_urls', ) upload_url = file_urls[0] - log.info(f"Received upload URL for batch: {batch_id}") + log.info(f'Received upload URL for batch: {batch_id}') return batch_id, upload_url @@ -295,10 +289,10 @@ def _upload_to_presigned_url(self, upload_url: str) -> None: """ Upload file to presigned URL (no authentication needed). """ - log.info(f"Uploading file to presigned URL") + log.info(f'Uploading file to presigned URL') try: - with open(self.file_path, "rb") as f: + with open(self.file_path, 'rb') as f: response = requests.put( upload_url, data=f, @@ -306,26 +300,24 @@ def _upload_to_presigned_url(self, upload_url: str) -> None: ) response.raise_for_status() except FileNotFoundError: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail=f"File not found: {self.file_path}" - ) + raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f'File not found: {self.file_path}') except requests.Timeout: raise HTTPException( status.HTTP_504_GATEWAY_TIMEOUT, - detail="File upload to presigned URL timed out", + detail='File upload to presigned URL timed out', ) except requests.HTTPError as e: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Failed to upload file to presigned URL: {e}", + detail=f'Failed to upload file to presigned URL: {e}', ) except Exception as e: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error uploading file: {str(e)}", + detail=f'Error uploading file: {str(e)}', ) - log.info("File uploaded successfully") + log.info('File uploaded successfully') def _poll_batch_status(self, batch_id: str, filename: str) -> dict: """ @@ -333,35 +325,35 @@ def _poll_batch_status(self, batch_id: str, filename: str) -> dict: Returns the result dict for the file. """ headers = { - "Authorization": f"Bearer {self.api_key}", + 'Authorization': f'Bearer {self.api_key}', } max_iterations = 300 # 10 minutes max (2 seconds per iteration) poll_interval = 2 # seconds - log.info(f"Polling batch status: {batch_id}") + log.info(f'Polling batch status: {batch_id}') for iteration in range(max_iterations): try: response = requests.get( - f"{self.api_url}/extract-results/batch/{batch_id}", + f'{self.api_url}/extract-results/batch/{batch_id}', headers=headers, timeout=30, ) response.raise_for_status() except requests.HTTPError as e: - error_detail = f"Failed to poll batch status: {e}" + error_detail = f'Failed to poll batch status: {e}' if e.response is not None: try: error_data = e.response.json() - error_detail += f" - {error_data.get('msg', error_data)}" - except: - error_detail += f" - {e.response.text}" + error_detail += f' - {error_data.get("msg", error_data)}' + except Exception: + error_detail += f' - {e.response.text}' raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=error_detail) except Exception as e: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error polling batch status: {str(e)}", + detail=f'Error polling batch status: {str(e)}', ) try: @@ -369,58 +361,56 @@ def _poll_batch_status(self, batch_id: str, filename: str) -> dict: except ValueError as e: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail=f"Invalid JSON response while polling: {e}", + detail=f'Invalid JSON response while polling: {e}', ) # Check for API error response - if result.get("code") != 0: + if result.get('code') != 0: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"MinerU Cloud API error: {result.get('msg', 'Unknown error')}", + detail=f'MinerU Cloud API error: {result.get("msg", "Unknown error")}', ) - data = result.get("data", {}) - extract_result = data.get("extract_result", []) + data = result.get('data', {}) + extract_result = data.get('extract_result', []) # Find our file in the batch results file_result = None for item in extract_result: - if item.get("file_name") == filename: + if item.get('file_name') == filename: file_result = item break if not file_result: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail=f"File {filename} not found in batch results", + detail=f'File {filename} not found in batch results', ) - state = file_result.get("state") + state = file_result.get('state') - if state == "done": - log.info(f"Processing complete for {filename}") + if state == 'done': + log.info(f'Processing complete for {filename}') return file_result - elif state == "failed": - error_msg = file_result.get("err_msg", "Unknown error") + elif state == 'failed': + error_msg = file_result.get('err_msg', 'Unknown error') raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"MinerU processing failed: {error_msg}", + detail=f'MinerU processing failed: {error_msg}', ) - elif state in ["waiting-file", "pending", "running", "converting"]: + elif state in ['waiting-file', 'pending', 'running', 'converting']: # Still processing if iteration % 10 == 0: # Log every 20 seconds - log.info( - f"Processing status: {state} (iteration {iteration + 1}/{max_iterations})" - ) + log.info(f'Processing status: {state} (iteration {iteration + 1}/{max_iterations})') time.sleep(poll_interval) else: - log.warning(f"Unknown state: {state}") + log.warning(f'Unknown state: {state}') time.sleep(poll_interval) # Timeout raise HTTPException( status.HTTP_504_GATEWAY_TIMEOUT, - detail="MinerU processing timed out after 10 minutes", + detail='MinerU processing timed out after 10 minutes', ) def _download_and_extract_zip(self, zip_url: str, filename: str) -> str: @@ -428,7 +418,7 @@ def _download_and_extract_zip(self, zip_url: str, filename: str) -> str: Download ZIP file from CDN and extract markdown content. Returns the markdown content as a string. """ - log.info(f"Downloading results from: {zip_url}") + log.info(f'Downloading results from: {zip_url}') try: response = requests.get(zip_url, timeout=60) @@ -436,23 +426,23 @@ def _download_and_extract_zip(self, zip_url: str, filename: str) -> str: except requests.HTTPError as e: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"Failed to download results ZIP: {e}", + detail=f'Failed to download results ZIP: {e}', ) except Exception as e: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error downloading results: {str(e)}", + detail=f'Error downloading results: {str(e)}', ) # Save ZIP to temporary file and extract try: - with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip: + with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_zip: tmp_zip.write(response.content) tmp_zip_path = tmp_zip.name with tempfile.TemporaryDirectory() as tmp_dir: # Extract ZIP - with zipfile.ZipFile(tmp_zip_path, "r") as zip_ref: + with zipfile.ZipFile(tmp_zip_path, 'r') as zip_ref: zip_ref.extractall(tmp_dir) # Find markdown file - search recursively for any .md file @@ -466,33 +456,27 @@ def _download_and_extract_zip(self, zip_url: str, filename: str) -> str: full_path = os.path.join(root, file) all_files.append(full_path) # Look for any .md file - if file.endswith(".md"): + if file.endswith('.md'): found_md_path = full_path - log.info(f"Found markdown file at: {full_path}") + log.info(f'Found markdown file at: {full_path}') try: - with open(full_path, "r", encoding="utf-8") as f: + with open(full_path, 'r', encoding='utf-8') as f: markdown_content = f.read() - if ( - markdown_content - ): # Use the first non-empty markdown file + if markdown_content: # Use the first non-empty markdown file break except Exception as e: - log.warning(f"Failed to read {full_path}: {e}") + log.warning(f'Failed to read {full_path}: {e}') if markdown_content: break if markdown_content is None: - log.error(f"Available files in ZIP: {all_files}") + log.error(f'Available files in ZIP: {all_files}') # Try to provide more helpful error message - md_files = [f for f in all_files if f.endswith(".md")] + md_files = [f for f in all_files if f.endswith('.md')] if md_files: - error_msg = ( - f"Found .md files but couldn't read them: {md_files}" - ) + error_msg = f"Found .md files but couldn't read them: {md_files}" else: - error_msg = ( - f"No .md files found in ZIP. Available files: {all_files}" - ) + error_msg = f'No .md files found in ZIP. Available files: {all_files}' raise HTTPException( status.HTTP_502_BAD_GATEWAY, detail=error_msg, @@ -504,21 +488,19 @@ def _download_and_extract_zip(self, zip_url: str, filename: str) -> str: except zipfile.BadZipFile as e: raise HTTPException( status.HTTP_502_BAD_GATEWAY, - detail=f"Invalid ZIP file received: {e}", + detail=f'Invalid ZIP file received: {e}', ) except Exception as e: raise HTTPException( status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error extracting ZIP: {str(e)}", + detail=f'Error extracting ZIP: {str(e)}', ) if not markdown_content: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail="Extracted markdown content is empty", + detail='Extracted markdown content is empty', ) - log.info( - f"Successfully extracted markdown content ({len(markdown_content)} characters)" - ) + log.info(f'Successfully extracted markdown content ({len(markdown_content)} characters)') return markdown_content diff --git a/backend/open_webui/retrieval/loaders/mistral.py b/backend/open_webui/retrieval/loaders/mistral.py index 68570757c8..e46863a96a 100644 --- a/backend/open_webui/retrieval/loaders/mistral.py +++ b/backend/open_webui/retrieval/loaders/mistral.py @@ -49,13 +49,11 @@ def __init__( enable_debug_logging: Enable detailed debug logs. """ if not api_key: - raise ValueError("API key cannot be empty.") + raise ValueError('API key cannot be empty.') if not os.path.exists(file_path): - raise FileNotFoundError(f"File not found at {file_path}") + raise FileNotFoundError(f'File not found at {file_path}') - self.base_url = ( - base_url.rstrip("/") if base_url else "https://api.mistral.ai/v1" - ) + self.base_url = base_url.rstrip('/') if base_url else 'https://api.mistral.ai/v1' self.api_key = api_key self.file_path = file_path self.timeout = timeout @@ -65,18 +63,10 @@ def __init__( # PERFORMANCE OPTIMIZATION: Differentiated timeouts for different operations # This prevents long-running OCR operations from affecting quick operations # and improves user experience by failing fast on operations that should be quick - self.upload_timeout = min( - timeout, 120 - ) # Cap upload at 2 minutes - prevents hanging on large files - self.url_timeout = ( - 30 # URL requests should be fast - fail quickly if API is slow - ) - self.ocr_timeout = ( - timeout # OCR can take the full timeout - this is the heavy operation - ) - self.cleanup_timeout = ( - 30 # Cleanup should be quick - don't hang on file deletion - ) + self.upload_timeout = min(timeout, 120) # Cap upload at 2 minutes - prevents hanging on large files + self.url_timeout = 30 # URL requests should be fast - fail quickly if API is slow + self.ocr_timeout = timeout # OCR can take the full timeout - this is the heavy operation + self.cleanup_timeout = 30 # Cleanup should be quick - don't hang on file deletion # PERFORMANCE OPTIMIZATION: Pre-compute file info to avoid repeated filesystem calls # This avoids multiple os.path.basename() and os.path.getsize() calls during processing @@ -85,8 +75,8 @@ def __init__( # ENHANCEMENT: Added User-Agent for better API tracking and debugging self.headers = { - "Authorization": f"Bearer {self.api_key}", - "User-Agent": "OpenWebUI-MistralLoader/2.0", # Helps API provider track usage + 'Authorization': f'Bearer {self.api_key}', + 'User-Agent': 'OpenWebUI-MistralLoader/2.0', # Helps API provider track usage } def _debug_log(self, message: str, *args) -> None: @@ -108,43 +98,39 @@ def _handle_response(self, response: requests.Response) -> Dict[str, Any]: return {} # Return empty dict if no content return response.json() except requests.exceptions.HTTPError as http_err: - log.error(f"HTTP error occurred: {http_err} - Response: {response.text}") + log.error(f'HTTP error occurred: {http_err} - Response: {response.text}') raise except requests.exceptions.RequestException as req_err: - log.error(f"Request exception occurred: {req_err}") + log.error(f'Request exception occurred: {req_err}') raise except ValueError as json_err: # Includes JSONDecodeError - log.error(f"JSON decode error: {json_err} - Response: {response.text}") + log.error(f'JSON decode error: {json_err} - Response: {response.text}') raise # Re-raise after logging - async def _handle_response_async( - self, response: aiohttp.ClientResponse - ) -> Dict[str, Any]: + async def _handle_response_async(self, response: aiohttp.ClientResponse) -> Dict[str, Any]: """Async version of response handling with better error info.""" try: response.raise_for_status() # Check content type - content_type = response.headers.get("content-type", "") - if "application/json" not in content_type: + content_type = response.headers.get('content-type', '') + if 'application/json' not in content_type: if response.status == 204: return {} text = await response.text() - raise ValueError( - f"Unexpected content type: {content_type}, body: {text[:200]}..." - ) + raise ValueError(f'Unexpected content type: {content_type}, body: {text[:200]}...') return await response.json() except aiohttp.ClientResponseError as e: - error_text = await response.text() if response else "No response" - log.error(f"HTTP {e.status}: {e.message} - Response: {error_text[:500]}") + error_text = await response.text() if response else 'No response' + log.error(f'HTTP {e.status}: {e.message} - Response: {error_text[:500]}') raise except aiohttp.ClientError as e: - log.error(f"Client error: {e}") + log.error(f'Client error: {e}') raise except Exception as e: - log.error(f"Unexpected error processing response: {e}") + log.error(f'Unexpected error processing response: {e}') raise def _is_retryable_error(self, error: Exception) -> bool: @@ -172,13 +158,11 @@ def _is_retryable_error(self, error: Exception) -> bool: return True # Timeouts might resolve on retry if isinstance(error, requests.exceptions.HTTPError): # Only retry on server errors (5xx) or rate limits (429) - if hasattr(error, "response") and error.response is not None: + if hasattr(error, 'response') and error.response is not None: status_code = error.response.status_code return status_code >= 500 or status_code == 429 return False - if isinstance( - error, (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError) - ): + if isinstance(error, (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError)): return True # Async network/timeout errors are retryable if isinstance(error, aiohttp.ClientResponseError): return error.status >= 500 or error.status == 429 @@ -204,8 +188,7 @@ def _retry_request_sync(self, request_func, *args, **kwargs): # Prevents overwhelming the server while ensuring reasonable retry delays wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds log.warning( - f"Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. " - f"Retrying in {wait_time}s..." + f'Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s...' ) time.sleep(wait_time) @@ -226,8 +209,7 @@ async def _retry_request_async(self, request_func, *args, **kwargs): # PERFORMANCE OPTIMIZATION: Non-blocking exponential backoff wait_time = min((2**attempt) + 0.5, 30) # Cap at 30 seconds log.warning( - f"Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. " - f"Retrying in {wait_time}s..." + f'Retryable error (attempt {attempt + 1}/{self.max_retries}): {e}. Retrying in {wait_time}s...' ) await asyncio.sleep(wait_time) # Non-blocking wait @@ -240,15 +222,15 @@ def _upload_file(self) -> str: Although streaming is not enabled for this endpoint, the file is opened in a context manager to minimize memory usage duration. """ - log.info("Uploading file to Mistral API") - url = f"{self.base_url}/files" + log.info('Uploading file to Mistral API') + url = f'{self.base_url}/files' def upload_request(): # MEMORY OPTIMIZATION: Use context manager to minimize file handle lifetime # This ensures the file is closed immediately after reading, reducing memory usage - with open(self.file_path, "rb") as f: - files = {"file": (self.file_name, f, "application/pdf")} - data = {"purpose": "ocr"} + with open(self.file_path, 'rb') as f: + files = {'file': (self.file_name, f, 'application/pdf')} + data = {'purpose': 'ocr'} # NOTE: stream=False is required for this endpoint # The Mistral API doesn't support chunked uploads for this endpoint @@ -265,42 +247,38 @@ def upload_request(): try: response_data = self._retry_request_sync(upload_request) - file_id = response_data.get("id") + file_id = response_data.get('id') if not file_id: - raise ValueError("File ID not found in upload response.") - log.info(f"File uploaded successfully. File ID: {file_id}") + raise ValueError('File ID not found in upload response.') + log.info(f'File uploaded successfully. File ID: {file_id}') return file_id except Exception as e: - log.error(f"Failed to upload file: {e}") + log.error(f'Failed to upload file: {e}') raise async def _upload_file_async(self, session: aiohttp.ClientSession) -> str: """Async file upload with streaming for better memory efficiency.""" - url = f"{self.base_url}/files" + url = f'{self.base_url}/files' async def upload_request(): # Create multipart writer for streaming upload - writer = aiohttp.MultipartWriter("form-data") + writer = aiohttp.MultipartWriter('form-data') # Add purpose field - purpose_part = writer.append("ocr") - purpose_part.set_content_disposition("form-data", name="purpose") + purpose_part = writer.append('ocr') + purpose_part.set_content_disposition('form-data', name='purpose') # Add file part with streaming file_part = writer.append_payload( aiohttp.streams.FilePayload( self.file_path, filename=self.file_name, - content_type="application/pdf", + content_type='application/pdf', ) ) - file_part.set_content_disposition( - "form-data", name="file", filename=self.file_name - ) + file_part.set_content_disposition('form-data', name='file', filename=self.file_name) - self._debug_log( - f"Uploading file: {self.file_name} ({self.file_size:,} bytes)" - ) + self._debug_log(f'Uploading file: {self.file_name} ({self.file_size:,} bytes)') async with session.post( url, @@ -312,48 +290,44 @@ async def upload_request(): response_data = await self._retry_request_async(upload_request) - file_id = response_data.get("id") + file_id = response_data.get('id') if not file_id: - raise ValueError("File ID not found in upload response.") + raise ValueError('File ID not found in upload response.') - log.info(f"File uploaded successfully. File ID: {file_id}") + log.info(f'File uploaded successfully. File ID: {file_id}') return file_id def _get_signed_url(self, file_id: str) -> str: """Retrieves a temporary signed URL for the uploaded file (sync version).""" - log.info(f"Getting signed URL for file ID: {file_id}") - url = f"{self.base_url}/files/{file_id}/url" - params = {"expiry": 1} - signed_url_headers = {**self.headers, "Accept": "application/json"} + log.info(f'Getting signed URL for file ID: {file_id}') + url = f'{self.base_url}/files/{file_id}/url' + params = {'expiry': 1} + signed_url_headers = {**self.headers, 'Accept': 'application/json'} def url_request(): - response = requests.get( - url, headers=signed_url_headers, params=params, timeout=self.url_timeout - ) + response = requests.get(url, headers=signed_url_headers, params=params, timeout=self.url_timeout) return self._handle_response(response) try: response_data = self._retry_request_sync(url_request) - signed_url = response_data.get("url") + signed_url = response_data.get('url') if not signed_url: - raise ValueError("Signed URL not found in response.") - log.info("Signed URL received.") + raise ValueError('Signed URL not found in response.') + log.info('Signed URL received.') return signed_url except Exception as e: - log.error(f"Failed to get signed URL: {e}") + log.error(f'Failed to get signed URL: {e}') raise - async def _get_signed_url_async( - self, session: aiohttp.ClientSession, file_id: str - ) -> str: + async def _get_signed_url_async(self, session: aiohttp.ClientSession, file_id: str) -> str: """Async signed URL retrieval.""" - url = f"{self.base_url}/files/{file_id}/url" - params = {"expiry": 1} + url = f'{self.base_url}/files/{file_id}/url' + params = {'expiry': 1} - headers = {**self.headers, "Accept": "application/json"} + headers = {**self.headers, 'Accept': 'application/json'} async def url_request(): - self._debug_log(f"Getting signed URL for file ID: {file_id}") + self._debug_log(f'Getting signed URL for file ID: {file_id}') async with session.get( url, headers=headers, @@ -364,69 +338,65 @@ async def url_request(): response_data = await self._retry_request_async(url_request) - signed_url = response_data.get("url") + signed_url = response_data.get('url') if not signed_url: - raise ValueError("Signed URL not found in response.") + raise ValueError('Signed URL not found in response.') - self._debug_log("Signed URL received successfully") + self._debug_log('Signed URL received successfully') return signed_url def _process_ocr(self, signed_url: str) -> Dict[str, Any]: """Sends the signed URL to the OCR endpoint for processing (sync version).""" - log.info("Processing OCR via Mistral API") - url = f"{self.base_url}/ocr" + log.info('Processing OCR via Mistral API') + url = f'{self.base_url}/ocr' ocr_headers = { **self.headers, - "Content-Type": "application/json", - "Accept": "application/json", + 'Content-Type': 'application/json', + 'Accept': 'application/json', } payload = { - "model": "mistral-ocr-latest", - "document": { - "type": "document_url", - "document_url": signed_url, + 'model': 'mistral-ocr-latest', + 'document': { + 'type': 'document_url', + 'document_url': signed_url, }, - "include_image_base64": False, + 'include_image_base64': False, } def ocr_request(): - response = requests.post( - url, headers=ocr_headers, json=payload, timeout=self.ocr_timeout - ) + response = requests.post(url, headers=ocr_headers, json=payload, timeout=self.ocr_timeout) return self._handle_response(response) try: ocr_response = self._retry_request_sync(ocr_request) - log.info("OCR processing done.") - self._debug_log("OCR response: %s", ocr_response) + log.info('OCR processing done.') + self._debug_log('OCR response: %s', ocr_response) return ocr_response except Exception as e: - log.error(f"Failed during OCR processing: {e}") + log.error(f'Failed during OCR processing: {e}') raise - async def _process_ocr_async( - self, session: aiohttp.ClientSession, signed_url: str - ) -> Dict[str, Any]: + async def _process_ocr_async(self, session: aiohttp.ClientSession, signed_url: str) -> Dict[str, Any]: """Async OCR processing with timing metrics.""" - url = f"{self.base_url}/ocr" + url = f'{self.base_url}/ocr' headers = { **self.headers, - "Content-Type": "application/json", - "Accept": "application/json", + 'Content-Type': 'application/json', + 'Accept': 'application/json', } payload = { - "model": "mistral-ocr-latest", - "document": { - "type": "document_url", - "document_url": signed_url, + 'model': 'mistral-ocr-latest', + 'document': { + 'type': 'document_url', + 'document_url': signed_url, }, - "include_image_base64": False, + 'include_image_base64': False, } async def ocr_request(): - log.info("Starting OCR processing via Mistral API") + log.info('Starting OCR processing via Mistral API') start_time = time.time() async with session.post( @@ -438,7 +408,7 @@ async def ocr_request(): ocr_response = await self._handle_response_async(response) processing_time = time.time() - start_time - log.info(f"OCR processing completed in {processing_time:.2f}s") + log.info(f'OCR processing completed in {processing_time:.2f}s') return ocr_response @@ -446,42 +416,36 @@ async def ocr_request(): def _delete_file(self, file_id: str) -> None: """Deletes the file from Mistral storage (sync version).""" - log.info(f"Deleting uploaded file ID: {file_id}") - url = f"{self.base_url}/files/{file_id}" + log.info(f'Deleting uploaded file ID: {file_id}') + url = f'{self.base_url}/files/{file_id}' try: - response = requests.delete( - url, headers=self.headers, timeout=self.cleanup_timeout - ) + response = requests.delete(url, headers=self.headers, timeout=self.cleanup_timeout) delete_response = self._handle_response(response) - log.info(f"File deleted successfully: {delete_response}") + log.info(f'File deleted successfully: {delete_response}') except Exception as e: # Log error but don't necessarily halt execution if deletion fails - log.error(f"Failed to delete file ID {file_id}: {e}") + log.error(f'Failed to delete file ID {file_id}: {e}') - async def _delete_file_async( - self, session: aiohttp.ClientSession, file_id: str - ) -> None: + async def _delete_file_async(self, session: aiohttp.ClientSession, file_id: str) -> None: """Async file deletion with error tolerance.""" try: async def delete_request(): - self._debug_log(f"Deleting file ID: {file_id}") + self._debug_log(f'Deleting file ID: {file_id}') async with session.delete( - url=f"{self.base_url}/files/{file_id}", + url=f'{self.base_url}/files/{file_id}', headers=self.headers, - timeout=aiohttp.ClientTimeout( - total=self.cleanup_timeout - ), # Shorter timeout for cleanup + timeout=aiohttp.ClientTimeout(total=self.cleanup_timeout), # Shorter timeout for cleanup ) as response: return await self._handle_response_async(response) await self._retry_request_async(delete_request) - self._debug_log(f"File {file_id} deleted successfully") + self._debug_log(f'File {file_id} deleted successfully') except Exception as e: # Don't fail the entire process if cleanup fails - log.warning(f"Failed to delete file ID {file_id}: {e}") + log.warning(f'Failed to delete file ID {file_id}: {e}') @asynccontextmanager async def _get_session(self): @@ -506,7 +470,7 @@ async def _get_session(self): async with aiohttp.ClientSession( connector=connector, timeout=timeout, - headers={"User-Agent": "OpenWebUI-MistralLoader/2.0"}, + headers={'User-Agent': 'OpenWebUI-MistralLoader/2.0'}, raise_for_status=False, # We handle status codes manually trust_env=True, ) as session: @@ -514,13 +478,13 @@ async def _get_session(self): def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]: """Process OCR results into Document objects with enhanced metadata and memory efficiency.""" - pages_data = ocr_response.get("pages") + pages_data = ocr_response.get('pages') if not pages_data: - log.warning("No pages found in OCR response.") + log.warning('No pages found in OCR response.') return [ Document( - page_content="No text content found", - metadata={"error": "no_pages", "file_name": self.file_name}, + page_content='No text content found', + metadata={'error': 'no_pages', 'file_name': self.file_name}, ) ] @@ -530,8 +494,8 @@ def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]: # Process pages in a memory-efficient way for page_data in pages_data: - page_content = page_data.get("markdown") - page_index = page_data.get("index") # API uses 0-based index + page_content = page_data.get('markdown') + page_index = page_data.get('index') # API uses 0-based index if page_content is None or page_index is None: skipped_pages += 1 @@ -548,7 +512,7 @@ def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]: if not cleaned_content: skipped_pages += 1 - self._debug_log(f"Skipping empty page {page_index}") + self._debug_log(f'Skipping empty page {page_index}') continue # Create document with optimized metadata @@ -556,34 +520,30 @@ def _process_results(self, ocr_response: Dict[str, Any]) -> List[Document]: Document( page_content=cleaned_content, metadata={ - "page": page_index, # 0-based index from API - "page_label": page_index + 1, # 1-based label for convenience - "total_pages": total_pages, - "file_name": self.file_name, - "file_size": self.file_size, - "processing_engine": "mistral-ocr", - "content_length": len(cleaned_content), + 'page': page_index, # 0-based index from API + 'page_label': page_index + 1, # 1-based label for convenience + 'total_pages': total_pages, + 'file_name': self.file_name, + 'file_size': self.file_size, + 'processing_engine': 'mistral-ocr', + 'content_length': len(cleaned_content), }, ) ) if skipped_pages > 0: - log.info( - f"Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages" - ) + log.info(f'Processed {len(documents)} pages, skipped {skipped_pages} empty/invalid pages') if not documents: # Case where pages existed but none had valid markdown/index - log.warning( - "OCR response contained pages, but none had valid content/index." - ) + log.warning('OCR response contained pages, but none had valid content/index.') return [ Document( - page_content="No valid text content found in document", + page_content='No valid text content found in document', metadata={ - "error": "no_valid_pages", - "total_pages": total_pages, - "file_name": self.file_name, + 'error': 'no_valid_pages', + 'total_pages': total_pages, + 'file_name': self.file_name, }, ) ] @@ -615,24 +575,20 @@ def load(self) -> List[Document]: documents = self._process_results(ocr_response) total_time = time.time() - start_time - log.info( - f"Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents" - ) + log.info(f'Sync OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents') return documents except Exception as e: total_time = time.time() - start_time - log.error( - f"An error occurred during the loading process after {total_time:.2f}s: {e}" - ) + log.error(f'An error occurred during the loading process after {total_time:.2f}s: {e}') # Return an error document on failure return [ Document( - page_content=f"Error during processing: {e}", + page_content=f'Error during processing: {e}', metadata={ - "error": "processing_failed", - "file_name": self.file_name, + 'error': 'processing_failed', + 'file_name': self.file_name, }, ) ] @@ -643,9 +599,7 @@ def load(self) -> List[Document]: self._delete_file(file_id) except Exception as del_e: # Log deletion error, but don't overwrite original error if one occurred - log.error( - f"Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}" - ) + log.error(f'Cleanup error: Could not delete file ID {file_id}. Reason: {del_e}') async def load_async(self) -> List[Document]: """ @@ -672,21 +626,19 @@ async def load_async(self) -> List[Document]: documents = self._process_results(ocr_response) total_time = time.time() - start_time - log.info( - f"Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents" - ) + log.info(f'Async OCR workflow completed in {total_time:.2f}s, produced {len(documents)} documents') return documents except Exception as e: total_time = time.time() - start_time - log.error(f"Async OCR workflow failed after {total_time:.2f}s: {e}") + log.error(f'Async OCR workflow failed after {total_time:.2f}s: {e}') return [ Document( - page_content=f"Error during OCR processing: {e}", + page_content=f'Error during OCR processing: {e}', metadata={ - "error": "processing_failed", - "file_name": self.file_name, + 'error': 'processing_failed', + 'file_name': self.file_name, }, ) ] @@ -697,11 +649,11 @@ async def load_async(self) -> List[Document]: async with self._get_session() as session: await self._delete_file_async(session, file_id) except Exception as cleanup_error: - log.error(f"Cleanup failed for file ID {file_id}: {cleanup_error}") + log.error(f'Cleanup failed for file ID {file_id}: {cleanup_error}') @staticmethod async def load_multiple_async( - loaders: List["MistralLoader"], + loaders: List['MistralLoader'], max_concurrent: int = 5, # Limit concurrent requests ) -> List[List[Document]]: """ @@ -717,15 +669,13 @@ async def load_multiple_async( if not loaders: return [] - log.info( - f"Starting concurrent processing of {len(loaders)} files with max {max_concurrent} concurrent" - ) + log.info(f'Starting concurrent processing of {len(loaders)} files with max {max_concurrent} concurrent') start_time = time.time() # Use semaphore to control concurrency semaphore = asyncio.Semaphore(max_concurrent) - async def process_with_semaphore(loader: "MistralLoader") -> List[Document]: + async def process_with_semaphore(loader: 'MistralLoader') -> List[Document]: async with semaphore: return await loader.load_async() @@ -737,14 +687,14 @@ async def process_with_semaphore(loader: "MistralLoader") -> List[Document]: processed_results = [] for i, result in enumerate(results): if isinstance(result, Exception): - log.error(f"File {i} failed: {result}") + log.error(f'File {i} failed: {result}') processed_results.append( [ Document( - page_content=f"Error processing file: {result}", + page_content=f'Error processing file: {result}', metadata={ - "error": "batch_processing_failed", - "file_index": i, + 'error': 'batch_processing_failed', + 'file_index': i, }, ) ] @@ -755,15 +705,13 @@ async def process_with_semaphore(loader: "MistralLoader") -> List[Document]: # MONITORING: Log comprehensive batch processing statistics total_time = time.time() - start_time total_docs = sum(len(docs) for docs in processed_results) - success_count = sum( - 1 for result in results if not isinstance(result, Exception) - ) + success_count = sum(1 for result in results if not isinstance(result, Exception)) failure_count = len(results) - success_count log.info( - f"Batch processing completed in {total_time:.2f}s: " - f"{success_count} files succeeded, {failure_count} files failed, " - f"produced {total_docs} total documents" + f'Batch processing completed in {total_time:.2f}s: ' + f'{success_count} files succeeded, {failure_count} files failed, ' + f'produced {total_docs} total documents' ) return processed_results diff --git a/backend/open_webui/retrieval/loaders/tavily.py b/backend/open_webui/retrieval/loaders/tavily.py index f298de80b4..742ac499cf 100644 --- a/backend/open_webui/retrieval/loaders/tavily.py +++ b/backend/open_webui/retrieval/loaders/tavily.py @@ -25,7 +25,7 @@ def __init__( self, urls: Union[str, List[str]], api_key: str, - extract_depth: Literal["basic", "advanced"] = "basic", + extract_depth: Literal['basic', 'advanced'] = 'basic', continue_on_failure: bool = True, ) -> None: """Initialize Tavily Extract client. @@ -42,13 +42,13 @@ def __init__( continue_on_failure: Whether to continue if extraction of a URL fails. """ if not urls: - raise ValueError("At least one URL must be provided.") + raise ValueError('At least one URL must be provided.') self.api_key = api_key self.urls = urls if isinstance(urls, list) else [urls] self.extract_depth = extract_depth self.continue_on_failure = continue_on_failure - self.api_url = "https://api.tavily.com/extract" + self.api_url = 'https://api.tavily.com/extract' def lazy_load(self) -> Iterator[Document]: """Extract and yield documents from the URLs using Tavily Extract API.""" @@ -57,35 +57,35 @@ def lazy_load(self) -> Iterator[Document]: batch_urls = self.urls[i : i + batch_size] try: headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.api_key}', } # Use string for single URL, array for multiple URLs urls_param = batch_urls[0] if len(batch_urls) == 1 else batch_urls - payload = {"urls": urls_param, "extract_depth": self.extract_depth} + payload = {'urls': urls_param, 'extract_depth': self.extract_depth} # Make the API call response = requests.post(self.api_url, headers=headers, json=payload) response.raise_for_status() response_data = response.json() # Process successful results - for result in response_data.get("results", []): - url = result.get("url", "") - content = result.get("raw_content", "") + for result in response_data.get('results', []): + url = result.get('url', '') + content = result.get('raw_content', '') if not content: - log.warning(f"No content extracted from {url}") + log.warning(f'No content extracted from {url}') continue # Add URLs as metadata - metadata = {"source": url} + metadata = {'source': url} yield Document( page_content=content, metadata=metadata, ) - for failed in response_data.get("failed_results", []): - url = failed.get("url", "") - error = failed.get("error", "Unknown error") - log.error(f"Failed to extract content from {url}: {error}") + for failed in response_data.get('failed_results', []): + url = failed.get('url', '') + error = failed.get('error', 'Unknown error') + log.error(f'Failed to extract content from {url}: {error}') except Exception as e: if self.continue_on_failure: - log.error(f"Error extracting content from batch {batch_urls}: {e}") + log.error(f'Error extracting content from batch {batch_urls}: {e}') else: raise e diff --git a/backend/open_webui/retrieval/loaders/youtube.py b/backend/open_webui/retrieval/loaders/youtube.py index faf7b4452e..34a1d20740 100644 --- a/backend/open_webui/retrieval/loaders/youtube.py +++ b/backend/open_webui/retrieval/loaders/youtube.py @@ -7,14 +7,14 @@ log = logging.getLogger(__name__) -ALLOWED_SCHEMES = {"http", "https"} +ALLOWED_SCHEMES = {'http', 'https'} ALLOWED_NETLOCS = { - "youtu.be", - "m.youtube.com", - "youtube.com", - "www.youtube.com", - "www.youtube-nocookie.com", - "vid.plus", + 'youtu.be', + 'm.youtube.com', + 'youtube.com', + 'www.youtube.com', + 'www.youtube-nocookie.com', + 'vid.plus', } @@ -30,17 +30,17 @@ def _parse_video_id(url: str) -> Optional[str]: path = parsed_url.path - if path.endswith("/watch"): + if path.endswith('/watch'): query = parsed_url.query parsed_query = parse_qs(query) - if "v" in parsed_query: - ids = parsed_query["v"] + if 'v' in parsed_query: + ids = parsed_query['v'] video_id = ids if isinstance(ids, str) else ids[0] else: return None else: - path = parsed_url.path.lstrip("/") - video_id = path.split("/")[-1] + path = parsed_url.path.lstrip('/') + video_id = path.split('/')[-1] if len(video_id) != 11: # Video IDs are 11 characters long return None @@ -54,13 +54,13 @@ class YoutubeLoader: def __init__( self, video_id: str, - language: Union[str, Sequence[str]] = "en", + language: Union[str, Sequence[str]] = 'en', proxy_url: Optional[str] = None, ): """Initialize with YouTube video ID.""" _video_id = _parse_video_id(video_id) self.video_id = _video_id if _video_id is not None else video_id - self._metadata = {"source": video_id} + self._metadata = {'source': video_id} self.proxy_url = proxy_url # Ensure language is a list @@ -70,8 +70,8 @@ def __init__( self.language = list(language) # Add English as fallback if not already in the list - if "en" not in self.language: - self.language.append("en") + if 'en' not in self.language: + self.language.append('en') def load(self) -> List[Document]: """Load YouTube transcripts into `Document` objects.""" @@ -85,14 +85,12 @@ def load(self) -> List[Document]: except ImportError: raise ImportError( 'Could not import "youtube_transcript_api" Python package. ' - "Please install it with `pip install youtube-transcript-api`." + 'Please install it with `pip install youtube-transcript-api`.' ) if self.proxy_url: - youtube_proxies = GenericProxyConfig( - http_url=self.proxy_url, https_url=self.proxy_url - ) - log.debug(f"Using proxy URL: {self.proxy_url[:14]}...") + youtube_proxies = GenericProxyConfig(http_url=self.proxy_url, https_url=self.proxy_url) + log.debug(f'Using proxy URL: {self.proxy_url[:14]}...') else: youtube_proxies = None @@ -100,7 +98,7 @@ def load(self) -> List[Document]: try: transcript_list = transcript_api.list(self.video_id) except Exception as e: - log.exception("Loading YouTube transcript failed") + log.exception('Loading YouTube transcript failed') return [] # Try each language in order of priority @@ -110,14 +108,10 @@ def load(self) -> List[Document]: if transcript.is_generated: log.debug(f"Found generated transcript for language '{lang}'") try: - transcript = transcript_list.find_manually_created_transcript( - [lang] - ) + transcript = transcript_list.find_manually_created_transcript([lang]) log.debug(f"Found manual transcript for language '{lang}'") except NoTranscriptFound: - log.debug( - f"No manual transcript found for language '{lang}', using generated" - ) + log.debug(f"No manual transcript found for language '{lang}', using generated") pass log.debug(f"Found transcript for language '{lang}'") @@ -131,12 +125,10 @@ def load(self) -> List[Document]: log.debug(f"Empty transcript for language '{lang}'") continue - transcript_text = " ".join( + transcript_text = ' '.join( map( lambda transcript_piece: ( - transcript_piece.text.strip(" ") - if hasattr(transcript_piece, "text") - else "" + transcript_piece.text.strip(' ') if hasattr(transcript_piece, 'text') else '' ), transcript_pieces, ) @@ -150,9 +142,9 @@ def load(self) -> List[Document]: raise e # If we get here, all languages failed - languages_tried = ", ".join(self.language) + languages_tried = ', '.join(self.language) log.warning( - f"No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed." + f'No transcript found for any of the specified languages: {languages_tried}. Verify if the video has transcripts, add more languages if needed.' ) raise NoTranscriptFound(self.video_id, self.language, list(transcript_list)) diff --git a/backend/open_webui/retrieval/models/colbert.py b/backend/open_webui/retrieval/models/colbert.py index 2a8c0329d7..d122291ec5 100644 --- a/backend/open_webui/retrieval/models/colbert.py +++ b/backend/open_webui/retrieval/models/colbert.py @@ -13,19 +13,17 @@ class ColBERT(BaseReranker): def __init__(self, name, **kwargs) -> None: - log.info("ColBERT: Loading model", name) - self.device = "cuda" if torch.cuda.is_available() else "cpu" + log.info('ColBERT: Loading model', name) + self.device = 'cuda' if torch.cuda.is_available() else 'cpu' - DOCKER = kwargs.get("env") == "docker" + DOCKER = kwargs.get('env') == 'docker' if DOCKER: # This is a workaround for the issue with the docker container # where the torch extension is not loaded properly # and the following error is thrown: # /root/.cache/torch_extensions/py311_cpu/segmented_maxsim_cpp/segmented_maxsim_cpp.so: cannot open shared object file: No such file or directory - lock_file = ( - "/root/.cache/torch_extensions/py311_cpu/segmented_maxsim_cpp/lock" - ) + lock_file = '/root/.cache/torch_extensions/py311_cpu/segmented_maxsim_cpp/lock' if os.path.exists(lock_file): os.remove(lock_file) @@ -36,23 +34,16 @@ def __init__(self, name, **kwargs) -> None: pass def calculate_similarity_scores(self, query_embeddings, document_embeddings): - query_embeddings = query_embeddings.to(self.device) document_embeddings = document_embeddings.to(self.device) # Validate dimensions to ensure compatibility if query_embeddings.dim() != 3: - raise ValueError( - f"Expected query embeddings to have 3 dimensions, but got {query_embeddings.dim()}." - ) + raise ValueError(f'Expected query embeddings to have 3 dimensions, but got {query_embeddings.dim()}.') if document_embeddings.dim() != 3: - raise ValueError( - f"Expected document embeddings to have 3 dimensions, but got {document_embeddings.dim()}." - ) + raise ValueError(f'Expected document embeddings to have 3 dimensions, but got {document_embeddings.dim()}.') if query_embeddings.size(0) not in [1, document_embeddings.size(0)]: - raise ValueError( - "There should be either one query or queries equal to the number of documents." - ) + raise ValueError('There should be either one query or queries equal to the number of documents.') # Transpose the query embeddings to align for matrix multiplication transposed_query_embeddings = query_embeddings.permute(0, 2, 1) @@ -69,7 +60,6 @@ def calculate_similarity_scores(self, query_embeddings, document_embeddings): return normalized_scores.detach().cpu().numpy().astype(np.float32) def predict(self, sentences): - query = sentences[0][0] docs = [i[1] for i in sentences] @@ -80,8 +70,6 @@ def predict(self, sentences): embedded_query = embedded_queries[0] # Calculate retrieval scores for the query against all documents - scores = self.calculate_similarity_scores( - embedded_query.unsqueeze(0), embedded_docs - ) + scores = self.calculate_similarity_scores(embedded_query.unsqueeze(0), embedded_docs) return scores diff --git a/backend/open_webui/retrieval/models/external.py b/backend/open_webui/retrieval/models/external.py index cd24dc6af2..f04583b965 100644 --- a/backend/open_webui/retrieval/models/external.py +++ b/backend/open_webui/retrieval/models/external.py @@ -15,8 +15,8 @@ class ExternalReranker(BaseReranker): def __init__( self, api_key: str, - url: str = "http://localhost:8080/v1/rerank", - model: str = "reranker", + url: str = 'http://localhost:8080/v1/rerank', + model: str = 'reranker', timeout: Optional[int] = None, ): self.api_key = api_key @@ -24,33 +24,31 @@ def __init__( self.model = model self.timeout = timeout - def predict( - self, sentences: List[Tuple[str, str]], user=None - ) -> Optional[List[float]]: + def predict(self, sentences: List[Tuple[str, str]], user=None) -> Optional[List[float]]: query = sentences[0][0] docs = [i[1] for i in sentences] payload = { - "model": self.model, - "query": query, - "documents": docs, - "top_n": len(docs), + 'model': self.model, + 'query': query, + 'documents': docs, + 'top_n': len(docs), } try: - log.info(f"ExternalReranker:predict:model {self.model}") - log.info(f"ExternalReranker:predict:query {query}") + log.info(f'ExternalReranker:predict:model {self.model}') + log.info(f'ExternalReranker:predict:query {query}') headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.api_key}', } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) r = requests.post( - f"{self.url}", + f'{self.url}', headers=headers, json=payload, timeout=self.timeout, @@ -60,13 +58,13 @@ def predict( r.raise_for_status() data = r.json() - if "results" in data: - sorted_results = sorted(data["results"], key=lambda x: x["index"]) - return [result["relevance_score"] for result in sorted_results] + if 'results' in data: + sorted_results = sorted(data['results'], key=lambda x: x['index']) + return [result['relevance_score'] for result in sorted_results] else: - log.error("No results found in external reranking response") + log.error('No results found in external reranking response') return None except Exception as e: - log.exception(f"Error in external reranking: {e}") + log.exception(f'Error in external reranking: {e}') return None diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 2469317f53..b6c46c1dad 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -60,7 +60,7 @@ def is_youtube_url(url: str) -> bool: - youtube_regex = r"^(https?://)?(www\.)?(youtube\.com|youtu\.be)/.+$" + youtube_regex = r'^(https?://)?(www\.)?(youtube\.com|youtu\.be)/.+$' return re.match(youtube_regex, url) is not None @@ -83,11 +83,11 @@ def get_loader(request, url: str): def get_content_from_url(request, url: str) -> str: loader = get_loader(request, url) docs = loader.load() - content = " ".join([doc.page_content for doc in docs]) + content = ' '.join([doc.page_content for doc in docs]) return content, docs -CHUNK_HASH_KEY = "_chunk_hash" +CHUNK_HASH_KEY = '_chunk_hash' def _content_hash(text: str) -> str: @@ -100,9 +100,7 @@ class VectorSearchRetriever(BaseRetriever): embedding_function: Any top_k: int - def _get_relevant_documents( - self, query: str, *, run_manager: CallbackManagerForRetrieverRun - ) -> list[Document]: + def _get_relevant_documents(self, query: str, *, run_manager: CallbackManagerForRetrieverRun) -> list[Document]: """Get documents relevant to a query. Args: @@ -144,11 +142,9 @@ async def _aget_relevant_documents( return results -def query_doc( - collection_name: str, query_embedding: list[float], k: int, user: UserModel = None -): +def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None): try: - log.debug(f"query_doc:doc {collection_name}") + log.debug(f'query_doc:doc {collection_name}') result = VECTOR_DB_CLIENT.search( collection_name=collection_name, vectors=[query_embedding], @@ -156,25 +152,25 @@ def query_doc( ) if result: - log.info(f"query_doc:result {result.ids} {result.metadatas}") + log.info(f'query_doc:result {result.ids} {result.metadatas}') return result except Exception as e: - log.exception(f"Error querying doc {collection_name} with limit {k}: {e}") + log.exception(f'Error querying doc {collection_name} with limit {k}: {e}') raise e def get_doc(collection_name: str, user: UserModel = None): try: - log.debug(f"get_doc:doc {collection_name}") + log.debug(f'get_doc:doc {collection_name}') result = VECTOR_DB_CLIENT.get(collection_name=collection_name) if result: - log.info(f"query_doc:result {result.ids} {result.metadatas}") + log.info(f'query_doc:result {result.ids} {result.metadatas}') return result except Exception as e: - log.exception(f"Error getting doc {collection_name}: {e}") + log.exception(f'Error getting doc {collection_name}: {e}') raise e @@ -185,33 +181,29 @@ def get_enriched_texts(collection_result: GetResult) -> list[str]: metadata_parts = [text] # Add filename (repeat twice for extra weight in BM25 scoring) - if metadata.get("name"): - filename = metadata["name"] - filename_tokens = ( - filename.replace("_", " ").replace("-", " ").replace(".", " ") - ) - metadata_parts.append( - f"Filename: {filename} {filename_tokens} {filename_tokens}" - ) + if metadata.get('name'): + filename = metadata['name'] + filename_tokens = filename.replace('_', ' ').replace('-', ' ').replace('.', ' ') + metadata_parts.append(f'Filename: {filename} {filename_tokens} {filename_tokens}') # Add title if available - if metadata.get("title"): - metadata_parts.append(f"Title: {metadata['title']}") + if metadata.get('title'): + metadata_parts.append(f'Title: {metadata["title"]}') # Add document section headings if available (from markdown splitter) - if metadata.get("headings") and isinstance(metadata["headings"], list): - headings = " > ".join(str(h) for h in metadata["headings"]) - metadata_parts.append(f"Section: {headings}") + if metadata.get('headings') and isinstance(metadata['headings'], list): + headings = ' > '.join(str(h) for h in metadata['headings']) + metadata_parts.append(f'Section: {headings}') # Add source URL/path if available - if metadata.get("source"): - metadata_parts.append(f"Source: {metadata['source']}") + if metadata.get('source'): + metadata_parts.append(f'Source: {metadata["source"]}') # Add snippet for web search results - if metadata.get("snippet"): - metadata_parts.append(f"Snippet: {metadata['snippet']}") + if metadata.get('snippet'): + metadata_parts.append(f'Snippet: {metadata["snippet"]}') - enriched_texts.append(" ".join(metadata_parts)) + enriched_texts.append(' '.join(metadata_parts)) return enriched_texts @@ -232,11 +224,11 @@ async def query_doc_with_hybrid_search( # First check if collection_result has the required attributes if ( not collection_result - or not hasattr(collection_result, "documents") - or not hasattr(collection_result, "metadatas") + or not hasattr(collection_result, 'documents') + or not hasattr(collection_result, 'metadatas') ): - log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}") - return {"documents": [], "metadatas": [], "distances": []} + log.warning(f'query_doc_with_hybrid_search:no_docs {collection_name}') + return {'documents': [], 'metadatas': [], 'distances': []} # Now safely check the documents content after confirming attributes exist if ( @@ -244,10 +236,10 @@ async def query_doc_with_hybrid_search( or len(collection_result.documents) == 0 or not collection_result.documents[0] ): - log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}") - return {"documents": [], "metadatas": [], "distances": []} + log.warning(f'query_doc_with_hybrid_search:no_docs {collection_name}') + return {'documents': [], 'metadatas': [], 'distances': []} - log.debug(f"query_doc_with_hybrid_search:doc {collection_name}") + log.debug(f'query_doc_with_hybrid_search:doc {collection_name}') original_texts = collection_result.documents[0] bm25_metadatas = [ @@ -255,11 +247,7 @@ async def query_doc_with_hybrid_search( for idx, meta in enumerate(collection_result.metadatas[0]) ] - bm25_texts = ( - get_enriched_texts(collection_result) - if enable_enriched_texts - else original_texts - ) + bm25_texts = get_enriched_texts(collection_result) if enable_enriched_texts else original_texts bm25_retriever = BM25Retriever.from_texts( texts=bm25_texts, @@ -306,15 +294,13 @@ async def query_doc_with_hybrid_search( result = await compression_retriever.ainvoke(query) - distances = [d.metadata.get("score") for d in result] + distances = [d.metadata.get('score') for d in result] documents = [d.page_content for d in result] metadatas = [d.metadata for d in result] # retrieve only min(k, k_reranker) items, sort and cut by distance if k < k_reranker if k < k_reranker: - sorted_items = sorted( - zip(distances, documents, metadatas), key=lambda x: x[0], reverse=True - ) + sorted_items = sorted(zip(distances, documents, metadatas), key=lambda x: x[0], reverse=True) sorted_items = sorted_items[:k] if sorted_items: @@ -323,18 +309,15 @@ async def query_doc_with_hybrid_search( distances, documents, metadatas = [], [], [] result = { - "distances": [distances], - "documents": [documents], - "metadatas": [metadatas], + 'distances': [distances], + 'documents': [documents], + 'metadatas': [metadatas], } - log.info( - "query_doc_with_hybrid_search:result " - + f'{result["metadatas"]} {result["distances"]}' - ) + log.info('query_doc_with_hybrid_search:result ' + f'{result["metadatas"]} {result["distances"]}') return result except Exception as e: - log.exception(f"Error querying doc {collection_name} with hybrid search: {e}") + log.exception(f'Error querying doc {collection_name} with hybrid search: {e}') raise e @@ -345,15 +328,15 @@ def merge_get_results(get_results: list[dict]) -> dict: combined_ids = [] for data in get_results: - combined_documents.extend(data["documents"][0]) - combined_metadatas.extend(data["metadatas"][0]) - combined_ids.extend(data["ids"][0]) + combined_documents.extend(data['documents'][0]) + combined_metadatas.extend(data['metadatas'][0]) + combined_ids.extend(data['ids'][0]) # Create the output dictionary result = { - "documents": [combined_documents], - "metadatas": [combined_metadatas], - "ids": [combined_ids], + 'documents': [combined_documents], + 'metadatas': [combined_metadatas], + 'ids': [combined_ids], } return result @@ -365,21 +348,19 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict: for data in query_results: if ( - len(data.get("distances", [])) == 0 - or len(data.get("documents", [])) == 0 - or len(data.get("metadatas", [])) == 0 + len(data.get('distances', [])) == 0 + or len(data.get('documents', [])) == 0 + or len(data.get('metadatas', [])) == 0 ): continue - distances = data["distances"][0] - documents = data["documents"][0] - metadatas = data["metadatas"][0] + distances = data['distances'][0] + documents = data['documents'][0] + metadatas = data['metadatas'][0] for distance, document, metadata in zip(distances, documents, metadatas): if isinstance(document, str): - doc_hash = hashlib.sha256( - document.encode() - ).hexdigest() # Compute a hash for uniqueness + doc_hash = hashlib.sha256(document.encode()).hexdigest() # Compute a hash for uniqueness if doc_hash not in combined.keys(): combined[doc_hash] = (distance, document, metadata) @@ -394,15 +375,13 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict: combined.sort(key=lambda x: x[0], reverse=True) # Slice to keep only the top k elements - sorted_distances, sorted_documents, sorted_metadatas = ( - zip(*combined[:k]) if combined else ([], [], []) - ) + sorted_distances, sorted_documents, sorted_metadatas = zip(*combined[:k]) if combined else ([], [], []) # Create and return the output dictionary return { - "distances": [list(sorted_distances)], - "documents": [list(sorted_documents)], - "metadatas": [list(sorted_metadatas)], + 'distances': [list(sorted_distances)], + 'documents': [list(sorted_documents)], + 'metadatas': [list(sorted_metadatas)], } @@ -416,7 +395,7 @@ def get_all_items_from_collections(collection_names: list[str]) -> dict: if result is not None: results.append(result.model_dump()) except Exception as e: - log.exception(f"Error when querying the collection: {e}") + log.exception(f'Error when querying the collection: {e}') else: pass @@ -424,11 +403,34 @@ def get_all_items_from_collections(collection_names: list[str]) -> dict: async def query_collection( + request, collection_names: list[str], queries: list[str], embedding_function, k: int, ) -> dict: + # When request is provided, try hybrid search + reranking if enabled + if request and request.app.state.config.ENABLE_RAG_HYBRID_SEARCH: + try: + reranking_function = ( + (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents)) + if request.app.state.RERANKING_FUNCTION + else None + ) + return await query_collection_with_hybrid_search( + collection_names=collection_names, + queries=queries, + embedding_function=embedding_function, + k=k, + reranking_function=reranking_function, + k_reranker=request.app.state.config.TOP_K_RERANKER, + r=request.app.state.config.RELEVANCE_THRESHOLD, + hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, + enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, + ) + except Exception as e: + log.debug(f'Hybrid search failed, falling back to vector search: {e}') + results = [] error = False @@ -444,24 +446,18 @@ def process_query_collection(collection_name, query_embedding): return result.model_dump(), None return None, None except Exception as e: - log.exception(f"Error when querying the collection: {e}") + log.exception(f'Error when querying the collection: {e}') return None, e # Generate all query embeddings (in one call) - query_embeddings = await embedding_function( - queries, prefix=RAG_EMBEDDING_QUERY_PREFIX - ) - log.debug( - f"query_collection: processing {len(queries)} queries across {len(collection_names)} collections" - ) + query_embeddings = await embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX) + log.debug(f'query_collection: processing {len(queries)} queries across {len(collection_names)} collections') with ThreadPoolExecutor() as executor: future_results = [] for query_embedding in query_embeddings: for collection_name in collection_names: - result = executor.submit( - process_query_collection, collection_name, query_embedding - ) + result = executor.submit(process_query_collection, collection_name, query_embedding) future_results.append(result) task_results = [future.result() for future in future_results] @@ -472,7 +468,7 @@ def process_query_collection(collection_name, query_embedding): results.append(result) if error and not results: - log.warning("All collection queries failed. No results returned.") + log.warning('All collection queries failed. No results returned.') return merge_and_sort_query_results(results, k=k) @@ -495,19 +491,13 @@ async def query_collection_with_hybrid_search( collection_results = {} for collection_name in collection_names: try: - log.debug( - f"query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}" - ) - collection_results[collection_name] = VECTOR_DB_CLIENT.get( - collection_name=collection_name - ) + log.debug(f'query_collection_with_hybrid_search:VECTOR_DB_CLIENT.get:collection {collection_name}') + collection_results[collection_name] = VECTOR_DB_CLIENT.get(collection_name=collection_name) except Exception as e: - log.exception(f"Failed to fetch collection {collection_name}: {e}") + log.exception(f'Failed to fetch collection {collection_name}: {e}') collection_results[collection_name] = None - log.info( - f"Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections..." - ) + log.info(f'Starting hybrid search for {len(queries)} queries in {len(collection_names)} collections...') async def process_query(collection_name, query): try: @@ -525,7 +515,7 @@ async def process_query(collection_name, query): ) return result, None except Exception as e: - log.exception(f"Error when querying the collection with hybrid_search: {e}") + log.exception(f'Error when querying the collection with hybrid_search: {e}') return None, e # Prepare tasks for all collections and queries @@ -538,9 +528,7 @@ async def process_query(collection_name, query): ] # Run all queries in parallel using asyncio.gather - task_results = await asyncio.gather( - *[process_query(collection_name, query) for collection_name, query in tasks] - ) + task_results = await asyncio.gather(*[process_query(collection_name, query) for collection_name, query in tasks]) for result, err in task_results: if err is not None: @@ -549,9 +537,7 @@ async def process_query(collection_name, query): results.append(result) if error and not results: - raise Exception( - "Hybrid search failed for all collections. Using Non-hybrid search as fallback." - ) + raise Exception('Hybrid search failed for all collections. Using Non-hybrid search as fallback.') return merge_and_sort_query_results(results, k=k) @@ -559,8 +545,8 @@ async def process_query(collection_name, query): async def agenerate_openai_batch_embeddings( model: str, texts: list[str], - url: str = "https://api.openai.com/v1", - key: str = "", + url: str = 'https://api.openai.com/v1', + key: str = '', prefix: str = None, user: UserModel = None, ) -> Optional[list[list[float]]]: @@ -569,16 +555,14 @@ async def agenerate_openai_batch_embeddings( check_credit_by_user_id(user_id=user.id, form_data={}, is_embedding=True) try: - log.debug( - f"agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}" - ) - form_data = {"input": texts, "model": model} + log.debug(f'agenerate_openai_batch_embeddings:model {model} batch size: {len(texts)}') + form_data = {'input': texts, 'model': model} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {key}", + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {key}', } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) @@ -587,7 +571,7 @@ async def agenerate_openai_batch_embeddings( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: async with session.post( - f"{url}/embeddings", + f'{url}/embeddings', headers=headers, json=form_data, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -599,28 +583,24 @@ async def agenerate_openai_batch_embeddings( with CreditDeduct( user=user, model_id=model, - body={ - "messages": [ - {"role": "user", "content": form_data["input"]} - ] - }, + body={'messages': [{'role': 'user', 'content': form_data['input']}]}, is_stream=False, is_embedding=True, ) as credit_deduct: - if "usage" in data: + if 'usage' in data: credit_deduct.is_official_usage = True - prompt_tokens = data["usage"]["prompt_tokens"] + prompt_tokens = data['usage']['prompt_tokens'] credit_deduct.usage.prompt_tokens = prompt_tokens credit_deduct.usage.total_tokens = prompt_tokens else: - credit_deduct.run(form_data["input"]) + credit_deduct.run(form_data['input']) - if "data" in data: - return [item["embedding"] for item in data["data"]] + if 'data' in data: + return [item['embedding'] for item in data['data']] else: - raise Exception("Something went wrong :/") + raise Exception('Something went wrong :/') except Exception as e: - log.exception(f"Error generating openai batch embeddings: {e}") + log.exception(f'Error generating openai batch embeddings: {e}') return None @@ -628,8 +608,8 @@ async def agenerate_azure_openai_batch_embeddings( model: str, texts: list[str], url: str, - key: str = "", - version: str = "", + key: str = '', + version: str = '', prefix: str = None, user: UserModel = None, ) -> Optional[list[list[float]]]: @@ -638,18 +618,16 @@ async def agenerate_azure_openai_batch_embeddings( check_credit_by_user_id(user_id=user.id, form_data={}, is_embedding=True) try: - log.debug( - f"agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}" - ) - form_data = {"input": texts} + log.debug(f'agenerate_azure_openai_batch_embeddings:deployment {model} batch size: {len(texts)}') + form_data = {'input': texts} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix - full_url = f"{url}/openai/deployments/{model}/embeddings?api-version={version}" + full_url = f'{url}/openai/deployments/{model}/embeddings?api-version={version}' headers = { - "Content-Type": "application/json", - "api-key": key, + 'Content-Type': 'application/json', + 'api-key': key, } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) @@ -666,28 +644,28 @@ async def agenerate_azure_openai_batch_embeddings( r.raise_for_status() data = await r.json() - input_text = str(form_data["input"]) + input_text = str(form_data['input']) with CreditDeduct( user=user, model_id=model, - body={"messages": [{"role": "user", "content": input_text}]}, + body={'messages': [{'role': 'user', 'content': input_text}]}, is_stream=False, is_embedding=True, ) as credit_deduct: - if "usage" in data: + if 'usage' in data: credit_deduct.is_official_usage = True - prompt_tokens = data["usage"]["prompt_tokens"] + prompt_tokens = data['usage']['prompt_tokens'] credit_deduct.usage.prompt_tokens = prompt_tokens credit_deduct.usage.total_tokens = prompt_tokens else: credit_deduct.run(input_text) - if "data" in data: - return [item["embedding"] for item in data["data"]] + if 'data' in data: + return [item['embedding'] for item in data['data']] else: - raise Exception("Something went wrong :/") + raise Exception('Something went wrong :/') except Exception as e: - log.exception(f"Error generating azure openai batch embeddings: {e}") + log.exception(f'Error generating azure openai batch embeddings: {e}') return None @@ -695,7 +673,7 @@ async def agenerate_ollama_batch_embeddings( model: str, texts: list[str], url: str, - key: str = "", + key: str = '', prefix: str = None, user: UserModel = None, ) -> Optional[list[list[float]]]: @@ -704,16 +682,14 @@ async def agenerate_ollama_batch_embeddings( check_credit_by_user_id(user_id=user.id, form_data={}, is_embedding=True) try: - log.debug( - f"agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}" - ) - form_data = {"input": texts, "model": model} + log.debug(f'agenerate_ollama_batch_embeddings:model {model} batch size: {len(texts)}') + form_data = {'input': texts, 'model': model} if isinstance(RAG_EMBEDDING_PREFIX_FIELD_NAME, str) and isinstance(prefix, str): form_data[RAG_EMBEDDING_PREFIX_FIELD_NAME] = prefix headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {key}", + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {key}', } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) @@ -722,7 +698,7 @@ async def agenerate_ollama_batch_embeddings( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: async with session.post( - f"{url}/api/embed", + f'{url}/api/embed', headers=headers, json=form_data, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -730,22 +706,22 @@ async def agenerate_ollama_batch_embeddings( r.raise_for_status() data = await r.json() - input_text = str(form_data["input"]) + input_text = str(form_data['input']) with CreditDeduct( user=user, model_id=model, - body={"messages": [{"role": "user", "content": input_text}]}, + body={'messages': [{'role': 'user', 'content': input_text}]}, is_stream=False, is_embedding=True, ) as credit_deduct: credit_deduct.run(input_text) - if "embeddings" in data: - return data["embeddings"] + if 'embeddings' in data: + return data['embeddings'] else: - raise Exception("Something went wrong :/") + raise Exception('Something went wrong :/') except Exception as e: - log.exception(f"Error generating ollama batch embeddings: {e}") + log.exception(f'Error generating ollama batch embeddings: {e}') return None @@ -760,7 +736,7 @@ def get_embedding_function( enable_async=True, concurrent_requests=0, ) -> Awaitable: - if embedding_engine == "": + if embedding_engine == '': # Sentence transformers: CPU-bound sync operation async def async_embedding_function(query, prefix=None, user=None): return await asyncio.to_thread( @@ -768,7 +744,7 @@ async def async_embedding_function(query, prefix=None, user=None): lambda query, prefix=None: embedding_function.encode( query, batch_size=int(embedding_batch_size), - **({"prompt": prefix} if prefix else {}), + **({'prompt': prefix} if prefix else {}), ).tolist() ), query, @@ -776,7 +752,7 @@ async def async_embedding_function(query, prefix=None, user=None): ) return async_embedding_function - elif embedding_engine in ["ollama", "openai", "azure_openai"]: + elif embedding_engine in ['ollama', 'openai', 'azure_openai']: embedding_function = lambda query, prefix=None, user=None: generate_embeddings( engine=embedding_engine, model=embedding_model, @@ -791,15 +767,10 @@ async def async_embedding_function(query, prefix=None, user=None): async def async_embedding_function(query, prefix=None, user=None): if isinstance(query, list): # Create batches - batches = [ - query[i : i + embedding_batch_size] - for i in range(0, len(query), embedding_batch_size) - ] + batches = [query[i : i + embedding_batch_size] for i in range(0, len(query), embedding_batch_size)] if enable_async: - log.debug( - f"generate_multiple_async: Processing {len(batches)} batches in parallel" - ) + log.debug(f'generate_multiple_async: Processing {len(batches)} batches in parallel') # Use semaphore to limit concurrent embedding API requests # 0 = unlimited (no semaphore) if concurrent_requests: @@ -807,37 +778,27 @@ async def async_embedding_function(query, prefix=None, user=None): async def generate_batch_with_semaphore(batch): async with semaphore: - return await embedding_function( - batch, prefix=prefix, user=user - ) + return await embedding_function(batch, prefix=prefix, user=user) - tasks = [ - generate_batch_with_semaphore(batch) for batch in batches - ] + tasks = [generate_batch_with_semaphore(batch) for batch in batches] else: - tasks = [ - embedding_function(batch, prefix=prefix, user=user) - for batch in batches - ] + tasks = [embedding_function(batch, prefix=prefix, user=user) for batch in batches] batch_results = await asyncio.gather(*tasks) else: - log.debug( - f"generate_multiple_async: Processing {len(batches)} batches sequentially" - ) + log.debug(f'generate_multiple_async: Processing {len(batches)} batches sequentially') batch_results = [] for batch in batches: - batch_results.append( - await embedding_function(batch, prefix=prefix, user=user) - ) + batch_results.append(await embedding_function(batch, prefix=prefix, user=user)) - # Flatten results + # Flatten results โ€” raise if any batch failed embeddings = [] - for batch_embeddings in batch_results: - if isinstance(batch_embeddings, list): - embeddings.extend(batch_embeddings) + for i, batch_embeddings in enumerate(batch_results): + if batch_embeddings is None: + raise Exception(f'Embedding generation failed for batch {i + 1}/{len(batches)}') + embeddings.extend(batch_embeddings) log.debug( - f"generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches" + f'generate_multiple_async: Generated {len(embeddings)} embeddings from {len(batches)} parallel batches' ) return embeddings else: @@ -845,7 +806,7 @@ async def generate_batch_with_semaphore(batch): return async_embedding_function else: - raise ValueError(f"Unknown embedding engine: {embedding_engine}") + raise ValueError(f'Unknown embedding engine: {embedding_engine}') async def generate_embeddings( @@ -855,35 +816,39 @@ async def generate_embeddings( prefix: Union[str, None] = None, **kwargs, ): - url = kwargs.get("url", "") - key = kwargs.get("key", "") - user = kwargs.get("user") + url = kwargs.get('url', '') + key = kwargs.get('key', '') + user = kwargs.get('user') if prefix is not None and RAG_EMBEDDING_PREFIX_FIELD_NAME is None: if isinstance(text, list): - text = [f"{prefix}{text_element}" for text_element in text] + text = [f'{prefix}{text_element}' for text_element in text] else: - text = f"{prefix}{text}" + text = f'{prefix}{text}' - if engine == "ollama": + if engine == 'ollama': embeddings = await agenerate_ollama_batch_embeddings( **{ - "model": model, - "texts": text if isinstance(text, list) else [text], - "url": url, - "key": key, - "prefix": prefix, - "user": user, + 'model': model, + 'texts': text if isinstance(text, list) else [text], + 'url': url, + 'key': key, + 'prefix': prefix, + 'user': user, } ) + if embeddings is None: + return None return embeddings[0] if isinstance(text, str) else embeddings - elif engine == "openai": + elif engine == 'openai': embeddings = await agenerate_openai_batch_embeddings( model, text if isinstance(text, list) else [text], url, key, prefix, user ) + if embeddings is None: + return None return embeddings[0] if isinstance(text, str) else embeddings - elif engine == "azure_openai": - azure_api_version = kwargs.get("azure_api_version", "") + elif engine == 'azure_openai': + azure_api_version = kwargs.get('azure_api_version', '') embeddings = await agenerate_azure_openai_batch_embeddings( model, text if isinstance(text, list) else [text], @@ -893,13 +858,15 @@ async def generate_embeddings( prefix, user, ) + if embeddings is None: + return None return embeddings[0] if isinstance(text, str) else embeddings def get_reranking_function(reranking_engine, reranking_model, reranking_function): if reranking_function is None: return None - if reranking_engine == "external": + if reranking_engine == 'external': return lambda query, documents, user=None: reranking_function.predict( [(query, doc.page_content) for doc in documents], user=user ) @@ -923,9 +890,7 @@ async def get_sources_from_items( full_context=False, user: Optional[UserModel] = None, ): - log.debug( - f"items: {items} {queries} {embedding_function} {reranking_function} {full_context}" - ) + log.debug(f'items: {items} {queries} {embedding_function} {reranking_function} {full_context}') extracted_collections = [] query_results = [] @@ -934,165 +899,146 @@ async def get_sources_from_items( query_result = None collection_names = [] - if item.get("type") == "text": + if item.get('type') == 'text': # Raw Text # Used during temporary chat file uploads or web page & youtube attachements - if item.get("context") == "full": - if item.get("file"): + if item.get('context') == 'full': + if item.get('file'): # if item has file data, use it query_result = { - "documents": [ - [item.get("file", {}).get("data", {}).get("content")] - ], - "metadatas": [[item.get("file", {}).get("meta", {})]], + 'documents': [[item.get('file', {}).get('data', {}).get('content')]], + 'metadatas': [[item.get('file', {}).get('meta', {})]], } if query_result is None: # Fallback - if item.get("collection_name"): + if item.get('collection_name'): # If item has a collection name, use it - collection_names.append(item.get("collection_name")) - elif item.get("file"): + collection_names.append(item.get('collection_name')) + elif item.get('file'): # If item has file data, use it query_result = { - "documents": [ - [item.get("file", {}).get("data", {}).get("content")] - ], - "metadatas": [[item.get("file", {}).get("meta", {})]], + 'documents': [[item.get('file', {}).get('data', {}).get('content')]], + 'metadatas': [[item.get('file', {}).get('meta', {})]], } else: # Fallback to item content query_result = { - "documents": [[item.get("content")]], - "metadatas": [ - [{"file_id": item.get("id"), "name": item.get("name")}] - ], + 'documents': [[item.get('content')]], + 'metadatas': [[{'file_id': item.get('id'), 'name': item.get('name')}]], } - elif item.get("type") == "note": + elif item.get('type') == 'note': # Note Attached - note = Notes.get_note_by_id(item.get("id")) + note = Notes.get_note_by_id(item.get('id')) if note and ( - user.role == "admin" + user.role == 'admin' or note.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="read", + permission='read', ) ): # User has access to the note query_result = { - "documents": [[note.data.get("content", {}).get("md", "")]], - "metadatas": [[{"file_id": note.id, "name": note.title}]], + 'documents': [[note.data.get('content', {}).get('md', '')]], + 'metadatas': [[{'file_id': note.id, 'name': note.title}]], } - elif item.get("type") == "chat": + elif item.get('type') == 'chat': # Chat Attached - chat = Chats.get_chat_by_id(item.get("id")) + chat = Chats.get_chat_by_id(item.get('id')) - if chat and (user.role == "admin" or chat.user_id == user.id): - messages_map = chat.chat.get("history", {}).get("messages", {}) - message_id = chat.chat.get("history", {}).get("currentId") + if chat and (user.role == 'admin' or chat.user_id == user.id): + messages_map = chat.chat.get('history', {}).get('messages', {}) + message_id = chat.chat.get('history', {}).get('currentId') if messages_map and message_id: # 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 - ] + message_history = '\n'.join( + [f'#### {m.get("role", "user").capitalize()}\n{m.get("content")}\n' for m in message_list] ) # User has access to the chat query_result = { - "documents": [[message_history]], - "metadatas": [[{"file_id": chat.id, "name": chat.title}]], + 'documents': [[message_history]], + 'metadatas': [[{'file_id': chat.id, 'name': chat.title}]], } - elif item.get("type") == "url": - content, docs = get_content_from_url(request, item.get("url")) + elif item.get('type') == 'url': + content, docs = get_content_from_url(request, item.get('url')) if docs: query_result = { - "documents": [[content]], - "metadatas": [[{"url": item.get("url"), "name": item.get("url")}]], + 'documents': [[content]], + 'metadatas': [[{'url': item.get('url'), 'name': item.get('url')}]], } - elif item.get("type") == "file": - if ( - item.get("context") == "full" - or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL - ): - if item.get("file", {}).get("data", {}).get("content", ""): + elif item.get('type') == 'file': + if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: + if item.get('file', {}).get('data', {}).get('content', ''): # Manual Full Mode Toggle # Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content") query_result = { - "documents": [ - [item.get("file", {}).get("data", {}).get("content", "")] - ], - "metadatas": [ + 'documents': [[item.get('file', {}).get('data', {}).get('content', '')]], + 'metadatas': [ [ { - "file_id": item.get("id"), - "name": item.get("name"), - **item.get("file") - .get("data", {}) - .get("metadata", {}), + 'file_id': item.get('id'), + 'name': item.get('name'), + **item.get('file').get('data', {}).get('metadata', {}), } ] ], } - elif item.get("id"): - file_object = Files.get_file_by_id(item.get("id")) + elif item.get('id'): + file_object = Files.get_file_by_id(item.get('id')) if file_object: query_result = { - "documents": [[file_object.data.get("content", "")]], - "metadatas": [ + 'documents': [[file_object.data.get('content', '')]], + 'metadatas': [ [ { - "file_id": item.get("id"), - "name": file_object.filename, - "source": file_object.filename, + 'file_id': item.get('id'), + 'name': file_object.filename, + 'source': file_object.filename, } ] ], } else: # Fallback to collection names - if item.get("legacy"): - collection_names.append(f"{item['id']}") + if item.get('legacy'): + collection_names.append(f'{item["id"]}') else: - collection_names.append(f"file-{item['id']}") + collection_names.append(f'file-{item["id"]}') - elif item.get("type") == "collection": + elif item.get('type') == 'collection': # Manual Full Mode Toggle for Collection - knowledge_base = Knowledges.get_knowledge_by_id(item.get("id")) + knowledge_base = Knowledges.get_knowledge_by_id(item.get('id')) if knowledge_base and ( - user.role == "admin" + user.role == 'admin' or knowledge_base.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge_base.id, - permission="read", + permission='read', ) ): - if ( - item.get("context") == "full" - or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL - ): + if item.get('context') == 'full' or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: if knowledge_base and ( - user.role == "admin" + user.role == 'admin' or knowledge_base.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge_base.id, - permission="read", + permission='read', ) ): files = Knowledges.get_files_by_id(knowledge_base.id) @@ -1100,100 +1046,80 @@ async def get_sources_from_items( documents = [] metadatas = [] for file in files: - documents.append(file.data.get("content", "")) + documents.append(file.data.get('content', '')) metadatas.append( { - "file_id": file.id, - "name": file.filename, - "source": file.filename, + 'file_id': file.id, + 'name': file.filename, + 'source': file.filename, } ) query_result = { - "documents": [documents], - "metadatas": [metadatas], + 'documents': [documents], + 'metadatas': [metadatas], } else: # Fallback to collection names - if item.get("legacy"): - collection_names = item.get("collection_names", []) + if item.get('legacy'): + collection_names = item.get('collection_names', []) else: - collection_names.append(item["id"]) + collection_names.append(item['id']) - elif item.get("docs"): + elif item.get('docs'): # BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL query_result = { - "documents": [[doc.get("content") for doc in item.get("docs")]], - "metadatas": [[doc.get("metadata") for doc in item.get("docs")]], + 'documents': [[doc.get('content') for doc in item.get('docs')]], + 'metadatas': [[doc.get('metadata') for doc in item.get('docs')]], } - elif item.get("collection_name"): + elif item.get('collection_name'): # Direct Collection Name - collection_names.append(item["collection_name"]) - elif item.get("collection_names"): + collection_names.append(item['collection_name']) + elif item.get('collection_names'): # Collection Names List - collection_names.extend(item["collection_names"]) + collection_names.extend(item['collection_names']) # If query_result is None # Fallback to collection names and vector search the collections if query_result is None and collection_names: collection_names = set(collection_names).difference(extracted_collections) if not collection_names: - log.debug(f"skipping {item} as it has already been extracted") + log.debug(f'skipping {item} as it has already been extracted') continue try: if full_context: query_result = get_all_items_from_collections(collection_names) else: - query_result = None # Initialize to None - if hybrid_search: - try: - query_result = await query_collection_with_hybrid_search( - collection_names=collection_names, - queries=queries, - embedding_function=embedding_function, - k=k, - reranking_function=reranking_function, - k_reranker=k_reranker, - r=r, - hybrid_bm25_weight=hybrid_bm25_weight, - enable_enriched_texts=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, - ) - except Exception as e: - log.debug( - "Error when using hybrid search, using non hybrid search as fallback." - ) - - # fallback to non-hybrid search - if not hybrid_search and query_result is None: - query_result = await query_collection( - collection_names=collection_names, - queries=queries, - embedding_function=embedding_function, - k=k, - ) + query_result = await query_collection( + request, + collection_names=collection_names, + queries=queries, + embedding_function=embedding_function, + k=k, + ) except Exception as e: log.exception(e) extracted_collections.extend(collection_names) if query_result: - if "data" in item: - del item["data"] - query_results.append({**query_result, "file": item}) + if 'data' in item: + del item['data'] + query_results.append({**query_result, 'file': item}) sources = [] for query_result in query_results: try: - if "documents" in query_result: - if "metadatas" in query_result: + if 'documents' in query_result: + if 'metadatas' in query_result: source = { - "source": query_result["file"], - "document": query_result["documents"][0], - "metadata": query_result["metadatas"][0], + 'source': query_result['file'], + 'document': query_result['documents'][0], + 'metadata': query_result['metadatas'][0], } - if "distances" in query_result and query_result["distances"]: - source["distances"] = query_result["distances"][0] + if 'distances' in query_result and query_result['distances']: + source['distances'] = query_result['distances'][0] sources.append(source) except Exception as e: @@ -1203,7 +1129,7 @@ async def get_sources_from_items( def get_model_path(model: str, update_model: bool = False): # Construct huggingface_hub kwargs with local_files_only to return the snapshot path - cache_dir = os.getenv("SENTENCE_TRANSFORMERS_HOME") + cache_dir = os.getenv('SENTENCE_TRANSFORMERS_HOME') local_files_only = not update_model @@ -1211,34 +1137,30 @@ def get_model_path(model: str, update_model: bool = False): local_files_only = True snapshot_kwargs = { - "cache_dir": cache_dir, - "local_files_only": local_files_only, + 'cache_dir': cache_dir, + 'local_files_only': local_files_only, } - log.debug(f"model: {model}") - log.debug(f"snapshot_kwargs: {snapshot_kwargs}") + log.debug(f'model: {model}') + log.debug(f'snapshot_kwargs: {snapshot_kwargs}') # Inspiration from upstream sentence_transformers - if ( - os.path.exists(model) - or ("\\" in model or model.count("/") > 1) - and local_files_only - ): + if os.path.exists(model) or ('\\' in model or model.count('/') > 1) and local_files_only: # If fully qualified path exists, return input, else set repo_id return model - elif "/" not in model: + elif '/' not in model: # Set valid repo_id for model short-name - model = "sentence-transformers" + "/" + model + model = 'sentence-transformers' + '/' + model - snapshot_kwargs["repo_id"] = model + snapshot_kwargs['repo_id'] = model # Attempt to query the huggingface_hub library to determine the local path and/or to update try: model_repo_path = snapshot_download(**snapshot_kwargs) - log.debug(f"model_repo_path: {model_repo_path}") + log.debug(f'model_repo_path: {model_repo_path}') return model_repo_path except Exception as e: - log.exception(f"Cannot determine model snapshot path: {e}") + log.exception(f'Cannot determine model snapshot path: {e}') if OFFLINE_MODE: raise return model @@ -1258,7 +1180,7 @@ class RerankCompressor(BaseDocumentCompressor): r_score: float class Config: - extra = "forbid" + extra = 'forbid' arbitrary_types_allowed = True def compress_documents( @@ -1294,9 +1216,7 @@ async def acompress_documents( else: from sentence_transformers import util - query_embedding = await self.embedding_function( - query, RAG_EMBEDDING_QUERY_PREFIX - ) + query_embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX) document_embedding = await self.embedding_function( [doc.page_content for doc in documents], RAG_EMBEDDING_CONTENT_PREFIX ) @@ -1310,15 +1230,13 @@ async def acompress_documents( ) ) if self.r_score: - docs_with_scores = [ - (d, s) for d, s in docs_with_scores if s >= self.r_score - ] + docs_with_scores = [(d, s) for d, s in docs_with_scores if s >= self.r_score] result = sorted(docs_with_scores, key=operator.itemgetter(1), reverse=True) final_results = [] for doc, doc_score in result[: self.top_n]: metadata = doc.metadata - metadata["score"] = doc_score + metadata['score'] = doc_score doc = Document( page_content=doc.page_content, metadata=metadata, @@ -1326,7 +1244,5 @@ async def acompress_documents( final_results.append(doc) return final_results else: - log.warning( - "No valid scores found, check your reranking function. Returning original documents." - ) + log.warning('No valid scores found, check your reranking function. Returning original documents.') return documents diff --git a/backend/open_webui/retrieval/vector/dbs/chroma.py b/backend/open_webui/retrieval/vector/dbs/chroma.py index b7ea5244b4..4ace732b2d 100755 --- a/backend/open_webui/retrieval/vector/dbs/chroma.py +++ b/backend/open_webui/retrieval/vector/dbs/chroma.py @@ -31,17 +31,15 @@ class ChromaClient(VectorDBBase): def __init__(self): settings_dict = { - "allow_reset": True, - "anonymized_telemetry": False, + 'allow_reset': True, + 'anonymized_telemetry': False, } if CHROMA_CLIENT_AUTH_PROVIDER is not None: - settings_dict["chroma_client_auth_provider"] = CHROMA_CLIENT_AUTH_PROVIDER + settings_dict['chroma_client_auth_provider'] = CHROMA_CLIENT_AUTH_PROVIDER if CHROMA_CLIENT_AUTH_CREDENTIALS is not None: - settings_dict["chroma_client_auth_credentials"] = ( - CHROMA_CLIENT_AUTH_CREDENTIALS - ) + settings_dict['chroma_client_auth_credentials'] = CHROMA_CLIENT_AUTH_CREDENTIALS - if CHROMA_HTTP_HOST != "": + if CHROMA_HTTP_HOST != '': self.client = chromadb.HttpClient( host=CHROMA_HTTP_HOST, port=CHROMA_HTTP_PORT, @@ -87,25 +85,23 @@ def search( # chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1 # https://docs.trychroma.com/docs/collections/configure cosine equation - distances: list = result["distances"][0] + distances: list = result['distances'][0] distances = [2 - dist for dist in distances] distances = [[dist / 2 for dist in distances]] return SearchResult( **{ - "ids": result["ids"], - "distances": distances, - "documents": result["documents"], - "metadatas": result["metadatas"], + 'ids': result['ids'], + 'distances': distances, + 'documents': result['documents'], + 'metadatas': result['metadatas'], } ) return None except Exception as e: return None - def query( - self, collection_name: str, filter: dict, limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) -> Optional[GetResult]: # Query the items from the collection based on the filter. try: collection = self.client.get_collection(name=collection_name) @@ -117,13 +113,13 @@ def query( return GetResult( **{ - "ids": [result["ids"]], - "documents": [result["documents"]], - "metadatas": [result["metadatas"]], + 'ids': [result['ids']], + 'documents': [result['documents']], + 'metadatas': [result['metadatas']], } ) return None - except: + except Exception: return None def get(self, collection_name: str) -> Optional[GetResult]: @@ -133,23 +129,21 @@ def get(self, collection_name: str) -> Optional[GetResult]: result = collection.get() return GetResult( **{ - "ids": [result["ids"]], - "documents": [result["documents"]], - "metadatas": [result["metadatas"]], + 'ids': [result['ids']], + 'documents': [result['documents']], + 'metadatas': [result['metadatas']], } ) return None def insert(self, collection_name: str, items: list[VectorItem]): # Insert the items into the collection, if the collection does not exist, it will be created. - collection = self.client.get_or_create_collection( - name=collection_name, metadata={"hnsw:space": "cosine"} - ) + collection = self.client.get_or_create_collection(name=collection_name, metadata={'hnsw:space': 'cosine'}) - ids = [item["id"] for item in items] - documents = [item["text"] for item in items] - embeddings = [item["vector"] for item in items] - metadatas = [process_metadata(item["metadata"]) for item in items] + ids = [item['id'] for item in items] + documents = [item['text'] for item in items] + embeddings = [item['vector'] for item in items] + metadatas = [process_metadata(item['metadata']) for item in items] for batch in create_batches( api=self.client, @@ -162,18 +156,14 @@ def insert(self, collection_name: str, items: list[VectorItem]): def upsert(self, collection_name: str, items: list[VectorItem]): # Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created. - collection = self.client.get_or_create_collection( - name=collection_name, metadata={"hnsw:space": "cosine"} - ) + collection = self.client.get_or_create_collection(name=collection_name, metadata={'hnsw:space': 'cosine'}) - ids = [item["id"] for item in items] - documents = [item["text"] for item in items] - embeddings = [item["vector"] for item in items] - metadatas = [process_metadata(item["metadata"]) for item in items] + ids = [item['id'] for item in items] + documents = [item['text'] for item in items] + embeddings = [item['vector'] for item in items] + metadatas = [process_metadata(item['metadata']) for item in items] - collection.upsert( - ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas - ) + collection.upsert(ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas) def delete( self, @@ -191,9 +181,7 @@ def delete( collection.delete(where=filter) except Exception as e: # If collection doesn't exist, that's fine - nothing to delete - log.debug( - f"Attempted to delete from non-existent collection {collection_name}. Ignoring." - ) + log.debug(f'Attempted to delete from non-existent collection {collection_name}. Ignoring.') pass def reset(self): diff --git a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py index dfb02ec029..201a5e1706 100644 --- a/backend/open_webui/retrieval/vector/dbs/elasticsearch.py +++ b/backend/open_webui/retrieval/vector/dbs/elasticsearch.py @@ -51,7 +51,7 @@ def __init__(self): # Status: works def _get_index_name(self, dimension: int) -> str: - return f"{self.index_prefix}_d{str(dimension)}" + return f'{self.index_prefix}_d{str(dimension)}' # Status: works def _scan_result_to_get_result(self, result) -> GetResult: @@ -62,24 +62,24 @@ def _scan_result_to_get_result(self, result) -> GetResult: metadatas = [] for hit in result: - ids.append(hit["_id"]) - documents.append(hit["_source"].get("text")) - metadatas.append(hit["_source"].get("metadata")) + ids.append(hit['_id']) + documents.append(hit['_source'].get('text')) + metadatas.append(hit['_source'].get('metadata')) return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) # Status: works def _result_to_get_result(self, result) -> GetResult: - if not result["hits"]["hits"]: + if not result['hits']['hits']: return None ids = [] documents = [] metadatas = [] - for hit in result["hits"]["hits"]: - ids.append(hit["_id"]) - documents.append(hit["_source"].get("text")) - metadatas.append(hit["_source"].get("metadata")) + for hit in result['hits']['hits']: + ids.append(hit['_id']) + documents.append(hit['_source'].get('text')) + metadatas.append(hit['_source'].get('metadata')) return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) @@ -90,11 +90,11 @@ def _result_to_search_result(self, result) -> SearchResult: documents = [] metadatas = [] - for hit in result["hits"]["hits"]: - ids.append(hit["_id"]) - distances.append(hit["_score"]) - documents.append(hit["_source"].get("text")) - metadatas.append(hit["_source"].get("metadata")) + for hit in result['hits']['hits']: + ids.append(hit['_id']) + distances.append(hit['_score']) + documents.append(hit['_source'].get('text')) + metadatas.append(hit['_source'].get('metadata')) return SearchResult( ids=[ids], @@ -106,26 +106,26 @@ def _result_to_search_result(self, result) -> SearchResult: # Status: works def _create_index(self, dimension: int): body = { - "mappings": { - "dynamic_templates": [ + 'mappings': { + 'dynamic_templates': [ { - "strings": { - "match_mapping_type": "string", - "mapping": {"type": "keyword"}, + 'strings': { + 'match_mapping_type': 'string', + 'mapping': {'type': 'keyword'}, } } ], - "properties": { - "collection": {"type": "keyword"}, - "id": {"type": "keyword"}, - "vector": { - "type": "dense_vector", - "dims": dimension, # Adjust based on your vector dimensions - "index": True, - "similarity": "cosine", + 'properties': { + 'collection': {'type': 'keyword'}, + 'id': {'type': 'keyword'}, + 'vector': { + 'type': 'dense_vector', + 'dims': dimension, # Adjust based on your vector dimensions + 'index': True, + 'similarity': 'cosine', }, - "text": {"type": "text"}, - "metadata": {"type": "object"}, + 'text': {'type': 'text'}, + 'metadata': {'type': 'object'}, }, } } @@ -139,21 +139,19 @@ def _create_batches(self, items: list[VectorItem], batch_size=100): # Status: works def has_collection(self, collection_name) -> bool: - query_body = {"query": {"bool": {"filter": []}}} - query_body["query"]["bool"]["filter"].append( - {"term": {"collection": collection_name}} - ) + query_body = {'query': {'bool': {'filter': []}}} + query_body['query']['bool']['filter'].append({'term': {'collection': collection_name}}) try: - result = self.client.count(index=f"{self.index_prefix}*", body=query_body) + result = self.client.count(index=f'{self.index_prefix}*', body=query_body) - return result.body["count"] > 0 + return result.body['count'] > 0 except Exception as e: return None def delete_collection(self, collection_name: str): - query = {"query": {"term": {"collection": collection_name}}} - self.client.delete_by_query(index=f"{self.index_prefix}*", body=query) + query = {'query': {'term': {'collection': collection_name}}} + self.client.delete_by_query(index=f'{self.index_prefix}*', body=query) # Status: works def search( @@ -164,51 +162,41 @@ def search( limit: int = 10, ) -> Optional[SearchResult]: query = { - "size": limit, - "_source": ["text", "metadata"], - "query": { - "script_score": { - "query": { - "bool": {"filter": [{"term": {"collection": collection_name}}]} - }, - "script": { - "source": "cosineSimilarity(params.vector, 'vector') + 1.0", - "params": { - "vector": vectors[0] - }, # Assuming single query vector + 'size': limit, + '_source': ['text', 'metadata'], + 'query': { + 'script_score': { + 'query': {'bool': {'filter': [{'term': {'collection': collection_name}}]}}, + 'script': { + 'source': "cosineSimilarity(params.vector, 'vector') + 1.0", + 'params': {'vector': vectors[0]}, # Assuming single query vector }, } }, } - result = self.client.search( - index=self._get_index_name(len(vectors[0])), body=query - ) + result = self.client.search(index=self._get_index_name(len(vectors[0])), body=query) return self._result_to_search_result(result) # Status: only tested halfwat - def query( - self, collection_name: str, filter: dict, limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) -> Optional[GetResult]: if not self.has_collection(collection_name): return None query_body = { - "query": {"bool": {"filter": []}}, - "_source": ["text", "metadata"], + 'query': {'bool': {'filter': []}}, + '_source': ['text', 'metadata'], } for field, value in filter.items(): - query_body["query"]["bool"]["filter"].append({"term": {field: value}}) - query_body["query"]["bool"]["filter"].append( - {"term": {"collection": collection_name}} - ) + query_body['query']['bool']['filter'].append({'term': {field: value}}) + query_body['query']['bool']['filter'].append({'term': {'collection': collection_name}}) size = limit if limit else 10 try: result = self.client.search( - index=f"{self.index_prefix}*", + index=f'{self.index_prefix}*', body=query_body, size=size, ) @@ -220,9 +208,7 @@ def query( # Status: works def _has_index(self, dimension: int): - return self.client.indices.exists( - index=self._get_index_name(dimension=dimension) - ) + return self.client.indices.exists(index=self._get_index_name(dimension=dimension)) def get_or_create_index(self, dimension: int): if not self._has_index(dimension=dimension): @@ -232,28 +218,28 @@ def get_or_create_index(self, dimension: int): def get(self, collection_name: str) -> Optional[GetResult]: # Get all the items in the collection. query = { - "query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}}, - "_source": ["text", "metadata"], + 'query': {'bool': {'filter': [{'term': {'collection': collection_name}}]}}, + '_source': ['text', 'metadata'], } - results = list(scan(self.client, index=f"{self.index_prefix}*", query=query)) + results = list(scan(self.client, index=f'{self.index_prefix}*', query=query)) return self._scan_result_to_get_result(results) # Status: works def insert(self, collection_name: str, items: list[VectorItem]): - if not self._has_index(dimension=len(items[0]["vector"])): - self._create_index(dimension=len(items[0]["vector"])) + if not self._has_index(dimension=len(items[0]['vector'])): + self._create_index(dimension=len(items[0]['vector'])) for batch in self._create_batches(items): actions = [ { - "_index": self._get_index_name(dimension=len(items[0]["vector"])), - "_id": item["id"], - "_source": { - "collection": collection_name, - "vector": item["vector"], - "text": item["text"], - "metadata": process_metadata(item["metadata"]), + '_index': self._get_index_name(dimension=len(items[0]['vector'])), + '_id': item['id'], + '_source': { + 'collection': collection_name, + 'vector': item['vector'], + 'text': item['text'], + 'metadata': process_metadata(item['metadata']), }, } for item in batch @@ -262,21 +248,21 @@ def insert(self, collection_name: str, items: list[VectorItem]): # Upsert documents using the update API with doc_as_upsert=True. def upsert(self, collection_name: str, items: list[VectorItem]): - if not self._has_index(dimension=len(items[0]["vector"])): - self._create_index(dimension=len(items[0]["vector"])) + if not self._has_index(dimension=len(items[0]['vector'])): + self._create_index(dimension=len(items[0]['vector'])) for batch in self._create_batches(items): actions = [ { - "_op_type": "update", - "_index": self._get_index_name(dimension=len(item["vector"])), - "_id": item["id"], - "doc": { - "collection": collection_name, - "vector": item["vector"], - "text": item["text"], - "metadata": process_metadata(item["metadata"]), + '_op_type': 'update', + '_index': self._get_index_name(dimension=len(item['vector'])), + '_id': item['id'], + 'doc': { + 'collection': collection_name, + 'vector': item['vector'], + 'text': item['text'], + 'metadata': process_metadata(item['metadata']), }, - "doc_as_upsert": True, + 'doc_as_upsert': True, } for item in batch ] @@ -289,22 +275,17 @@ def delete( ids: Optional[list[str]] = None, filter: Optional[dict] = None, ): - - query = { - "query": {"bool": {"filter": [{"term": {"collection": collection_name}}]}} - } + query = {'query': {'bool': {'filter': [{'term': {'collection': collection_name}}]}}} # logic based on chromaDB if ids: - query["query"]["bool"]["filter"].append({"terms": {"_id": ids}}) + query['query']['bool']['filter'].append({'terms': {'_id': ids}}) elif filter: for field, value in filter.items(): - query["query"]["bool"]["filter"].append( - {"term": {f"metadata.{field}": value}} - ) + query['query']['bool']['filter'].append({'term': {f'metadata.{field}': value}}) - self.client.delete_by_query(index=f"{self.index_prefix}*", body=query) + self.client.delete_by_query(index=f'{self.index_prefix}*', body=query) def reset(self): - indices = self.client.indices.get(index=f"{self.index_prefix}*") + indices = self.client.indices.get(index=f'{self.index_prefix}*') for index in indices: self.client.indices.delete(index=index) diff --git a/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py b/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py index dfcfb3da59..1cb3563382 100644 --- a/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py +++ b/backend/open_webui/retrieval/vector/dbs/mariadb_vector.py @@ -47,8 +47,8 @@ def _embedding_to_f32_bytes(vec: List[float]) -> bytes: byte sequence. We use array('f') to avoid a numpy dependency and byteswap on big-endian platforms for portability. """ - a = array.array("f", [float(x) for x in vec]) # float32 - if sys.byteorder != "little": + a = array.array('f', [float(x) for x in vec]) # float32 + if sys.byteorder != 'little': a.byteswap() return a.tobytes() @@ -68,7 +68,7 @@ def _safe_json(v: Any) -> Dict[str, Any]: return v if isinstance(v, (bytes, bytearray)): try: - v = v.decode("utf-8") + v = v.decode('utf-8') except Exception: return {} if isinstance(v, str): @@ -105,16 +105,16 @@ def __init__( """ self.db_url = (db_url or MARIADB_VECTOR_DB_URL).strip() self.vector_length = int(vector_length) - self.distance_strategy = (distance_strategy or "cosine").strip().lower() + self.distance_strategy = (distance_strategy or 'cosine').strip().lower() self.index_m = int(index_m) - if self.distance_strategy not in {"cosine", "euclidean"}: + if self.distance_strategy not in {'cosine', 'euclidean'}: raise ValueError("distance_strategy must be 'cosine' or 'euclidean'") - if not self.db_url.lower().startswith("mariadb+mariadbconnector://"): + if not self.db_url.lower().startswith('mariadb+mariadbconnector://'): raise ValueError( - "MariaDBVectorClient requires mariadb+mariadbconnector:// (official MariaDB driver) " - "to ensure qmark paramstyle and correct VECTOR binding." + 'MariaDBVectorClient requires mariadb+mariadbconnector:// (official MariaDB driver) ' + 'to ensure qmark paramstyle and correct VECTOR binding.' ) if isinstance(MARIADB_VECTOR_POOL_SIZE, int): @@ -129,9 +129,7 @@ def __init__( poolclass=QueuePool, ) else: - self.engine = create_engine( - self.db_url, pool_pre_ping=True, poolclass=NullPool - ) + self.engine = create_engine(self.db_url, pool_pre_ping=True, poolclass=NullPool) else: self.engine = create_engine(self.db_url, pool_pre_ping=True) self._init_schema() @@ -185,7 +183,7 @@ def _init_schema(self) -> None: conn.commit() except Exception as e: conn.rollback() - log.exception(f"Error during database initialization: {e}") + log.exception(f'Error during database initialization: {e}') raise def _check_vector_length(self) -> None: @@ -197,19 +195,19 @@ def _check_vector_length(self) -> None: """ with self._connect() as conn: with conn.cursor() as cur: - cur.execute("SHOW CREATE TABLE document_chunk") + cur.execute('SHOW CREATE TABLE document_chunk') row = cur.fetchone() if not row or len(row) < 2: return ddl = row[1] - m = re.search(r"vector\\((\\d+)\\)", ddl, flags=re.IGNORECASE) + m = re.search(r'vector\\((\\d+)\\)', ddl, flags=re.IGNORECASE) if not m: return existing = int(m.group(1)) if existing != int(self.vector_length): raise Exception( - f"VECTOR_LENGTH {self.vector_length} does not match existing vector column dimension {existing}. " - "Cannot change vector size after initialization without migrating the data." + f'VECTOR_LENGTH {self.vector_length} does not match existing vector column dimension {existing}. ' + 'Cannot change vector size after initialization without migrating the data.' ) def adjust_vector_length(self, vector: List[float]) -> List[float]: @@ -227,11 +225,7 @@ def _dist_fn(self) -> str: """ Return the MariaDB Vector distance function name for the configured strategy. """ - return ( - "vec_distance_cosine" - if self.distance_strategy == "cosine" - else "vec_distance_euclidean" - ) + return 'vec_distance_cosine' if self.distance_strategy == 'cosine' else 'vec_distance_euclidean' def _score_from_dist(self, dist: float) -> float: """ @@ -240,7 +234,7 @@ def _score_from_dist(self, dist: float) -> float: - cosine: score ~= 1 - cosine_distance, clamped to [0, 1] - euclidean: score = 1 / (1 + dist) """ - if self.distance_strategy == "cosine": + if self.distance_strategy == 'cosine': score = 1.0 - dist if score < 0.0: score = 0.0 @@ -260,48 +254,48 @@ def _build_filter_sql_qmark(self, expr: Any) -> Tuple[str, List[Any]]: - {"$or": [ ... ]} """ if not expr or not isinstance(expr, dict): - return "", [] + return '', [] - if "$and" in expr: + if '$and' in expr: parts: List[str] = [] params: List[Any] = [] - for e in expr.get("$and") or []: + for e in expr.get('$and') or []: s, p = self._build_filter_sql_qmark(e) if s: parts.append(s) params.extend(p) - return ("(" + " AND ".join(parts) + ")") if parts else "", params + return ('(' + ' AND '.join(parts) + ')') if parts else '', params - if "$or" in expr: + if '$or' in expr: parts: List[str] = [] params: List[Any] = [] - for e in expr.get("$or") or []: + for e in expr.get('$or') or []: s, p = self._build_filter_sql_qmark(e) if s: parts.append(s) params.extend(p) - return ("(" + " OR ".join(parts) + ")") if parts else "", params + return ('(' + ' OR '.join(parts) + ')') if parts else '', params clauses: List[str] = [] params: List[Any] = [] for key, value in expr.items(): - if key.startswith("$"): + if key.startswith('$'): continue json_expr = f"JSON_UNQUOTE(JSON_EXTRACT(vmetadata, '$.{key}'))" - if isinstance(value, dict) and "$in" in value: - vals = [str(v) for v in (value.get("$in") or [])] + if isinstance(value, dict) and '$in' in value: + vals = [str(v) for v in (value.get('$in') or [])] if not vals: - clauses.append("0=1") + clauses.append('0=1') continue ors = [] for v in vals: - ors.append(f"{json_expr} = ?") + ors.append(f'{json_expr} = ?') params.append(v) - clauses.append("(" + " OR ".join(ors) + ")") + clauses.append('(' + ' OR '.join(ors) + ')') else: - clauses.append(f"{json_expr} = ?") + clauses.append(f'{json_expr} = ?') params.append(str(value)) - return ("(" + " AND ".join(clauses) + ")") if clauses else "", params + return ('(' + ' AND '.join(clauses) + ')') if clauses else '', params def insert(self, collection_name: str, items: List[VectorItem]) -> None: """ @@ -322,15 +316,15 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: """ params: List[Tuple[Any, ...]] = [] for item in items: - v = self.adjust_vector_length(item["vector"]) + v = self.adjust_vector_length(item['vector']) emb = _embedding_to_f32_bytes(v) - meta = process_metadata(item.get("metadata") or {}) + meta = process_metadata(item.get('metadata') or {}) params.append( ( - item["id"], + item['id'], emb, collection_name, - item.get("text"), + item.get('text'), json.dumps(meta), ) ) @@ -338,7 +332,7 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: conn.commit() except Exception as e: conn.rollback() - log.exception(f"Error during insert: {e}") + log.exception(f'Error during insert: {e}') raise def upsert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -365,15 +359,15 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: """ params: List[Tuple[Any, ...]] = [] for item in items: - v = self.adjust_vector_length(item["vector"]) + v = self.adjust_vector_length(item['vector']) emb = _embedding_to_f32_bytes(v) - meta = process_metadata(item.get("metadata") or {}) + meta = process_metadata(item.get('metadata') or {}) params.append( ( - item["id"], + item['id'], emb, collection_name, - item.get("text"), + item.get('text'), json.dumps(meta), ) ) @@ -381,7 +375,7 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: conn.commit() except Exception as e: conn.rollback() - log.exception(f"Error during upsert: {e}") + log.exception(f'Error during upsert: {e}') raise def search( @@ -415,10 +409,10 @@ def search( with self._connect() as conn: with conn.cursor() as cur: fsql, fparams = self._build_filter_sql_qmark(filter or {}) - where = "collection_name = ?" + where = 'collection_name = ?' base_params: List[Any] = [collection_name] if fsql: - where = where + " AND " + fsql + where = where + ' AND ' + fsql base_params.extend(fparams) sql = f""" @@ -460,26 +454,24 @@ def search( metadatas=metadatas, ) except Exception as e: - log.exception(f"[MARIADB_VECTOR] search() failed: {e}") + log.exception(f'[MARIADB_VECTOR] search() failed: {e}') return None - def query( - self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]: """ Retrieve documents by metadata filter (non-vector query). """ with self._connect() as conn: with conn.cursor() as cur: fsql, fparams = self._build_filter_sql_qmark(filter or {}) - where = "collection_name = ?" + where = 'collection_name = ?' params: List[Any] = [collection_name] if fsql: - where = where + " AND " + fsql + where = where + ' AND ' + fsql params.extend(fparams) - sql = f"SELECT id, text, vmetadata FROM document_chunk WHERE {where}" + sql = f'SELECT id, text, vmetadata FROM document_chunk WHERE {where}' if limit is not None: - sql += " LIMIT ?" + sql += ' LIMIT ?' params.append(int(limit)) cur.execute(sql, params) rows = cur.fetchall() @@ -490,18 +482,16 @@ def query( metadatas = [[_safe_json(r[2]) for r in rows]] return GetResult(ids=ids, documents=documents, metadatas=metadatas) - def get( - self, collection_name: str, limit: Optional[int] = None - ) -> Optional[GetResult]: + def get(self, collection_name: str, limit: Optional[int] = None) -> Optional[GetResult]: """ Retrieve documents in a collection without filtering (optionally limited). """ with self._connect() as conn: with conn.cursor() as cur: - sql = "SELECT id, text, vmetadata FROM document_chunk WHERE collection_name = ?" + sql = 'SELECT id, text, vmetadata FROM document_chunk WHERE collection_name = ?' params: List[Any] = [collection_name] if limit is not None: - sql += " LIMIT ?" + sql += ' LIMIT ?' params.append(int(limit)) cur.execute(sql, params) rows = cur.fetchall() @@ -526,12 +516,12 @@ def delete( with self._connect() as conn: with conn.cursor() as cur: try: - where = ["collection_name = ?"] + where = ['collection_name = ?'] params: List[Any] = [collection_name] if ids: - ph = ", ".join(["?"] * len(ids)) - where.append(f"id IN ({ph})") + ph = ', '.join(['?'] * len(ids)) + where.append(f'id IN ({ph})') params.extend(ids) if filter: @@ -540,12 +530,12 @@ def delete( where.append(fsql) params.extend(fparams) - sql = "DELETE FROM document_chunk WHERE " + " AND ".join(where) + sql = 'DELETE FROM document_chunk WHERE ' + ' AND '.join(where) cur.execute(sql, params) conn.commit() except Exception as e: conn.rollback() - log.exception(f"Error during delete: {e}") + log.exception(f'Error during delete: {e}') raise def reset(self) -> None: @@ -555,11 +545,11 @@ def reset(self) -> None: with self._connect() as conn: with conn.cursor() as cur: try: - cur.execute("TRUNCATE TABLE document_chunk") + cur.execute('TRUNCATE TABLE document_chunk') conn.commit() except Exception as e: conn.rollback() - log.exception(f"Error during reset: {e}") + log.exception(f'Error during reset: {e}') raise def has_collection(self, collection_name: str) -> bool: @@ -570,7 +560,7 @@ def has_collection(self, collection_name: str) -> bool: with self._connect() as conn: with conn.cursor() as cur: cur.execute( - "SELECT 1 FROM document_chunk WHERE collection_name = ? LIMIT 1", + 'SELECT 1 FROM document_chunk WHERE collection_name = ? LIMIT 1', (collection_name,), ) return cur.fetchone() is not None @@ -590,4 +580,4 @@ def close(self) -> None: try: self.engine.dispose() except Exception as e: - log.exception(f"Error during dispose the underlying SQLAlchemy engine: {e}") + log.exception(f'Error during dispose the underlying SQLAlchemy engine: {e}') diff --git a/backend/open_webui/retrieval/vector/dbs/milvus.py b/backend/open_webui/retrieval/vector/dbs/milvus.py index 4dcf76c64d..2f3d8f3890 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus.py @@ -35,7 +35,7 @@ class MilvusClient(VectorDBBase): def __init__(self): - self.collection_prefix = "open_webui" + self.collection_prefix = 'open_webui' if MILVUS_TOKEN is None: self.client = Client(uri=MILVUS_URI, db_name=MILVUS_DB) else: @@ -50,17 +50,17 @@ def _result_to_get_result(self, result) -> GetResult: _documents = [] _metadatas = [] for item in match: - _ids.append(item.get("id")) - _documents.append(item.get("data", {}).get("text")) - _metadatas.append(item.get("metadata")) + _ids.append(item.get('id')) + _documents.append(item.get('data', {}).get('text')) + _metadatas.append(item.get('metadata')) ids.append(_ids) documents.append(_documents) metadatas.append(_metadatas) return GetResult( **{ - "ids": ids, - "documents": documents, - "metadatas": metadatas, + 'ids': ids, + 'documents': documents, + 'metadatas': metadatas, } ) @@ -75,23 +75,23 @@ def _result_to_search_result(self, result) -> SearchResult: _documents = [] _metadatas = [] for item in match: - _ids.append(item.get("id")) + _ids.append(item.get('id')) # normalize milvus score from [-1, 1] to [0, 1] range # https://milvus.io/docs/de/metric.md - _dist = (item.get("distance") + 1.0) / 2.0 + _dist = (item.get('distance') + 1.0) / 2.0 _distances.append(_dist) - _documents.append(item.get("entity", {}).get("data", {}).get("text")) - _metadatas.append(item.get("entity", {}).get("metadata")) + _documents.append(item.get('entity', {}).get('data', {}).get('text')) + _metadatas.append(item.get('entity', {}).get('metadata')) ids.append(_ids) distances.append(_distances) documents.append(_documents) metadatas.append(_metadatas) return SearchResult( **{ - "ids": ids, - "distances": distances, - "documents": documents, - "metadatas": metadatas, + 'ids': ids, + 'distances': distances, + 'documents': documents, + 'metadatas': metadatas, } ) @@ -101,21 +101,19 @@ def _create_collection(self, collection_name: str, dimension: int): enable_dynamic_field=True, ) schema.add_field( - field_name="id", + field_name='id', datatype=DataType.VARCHAR, is_primary=True, max_length=65535, ) schema.add_field( - field_name="vector", + field_name='vector', datatype=DataType.FLOAT_VECTOR, dim=dimension, - description="vector", - ) - schema.add_field(field_name="data", datatype=DataType.JSON, description="data") - schema.add_field( - field_name="metadata", datatype=DataType.JSON, description="metadata" + description='vector', ) + schema.add_field(field_name='data', datatype=DataType.JSON, description='data') + schema.add_field(field_name='metadata', datatype=DataType.JSON, description='metadata') index_params = self.client.prepare_index_params() @@ -123,44 +121,44 @@ def _create_collection(self, collection_name: str, dimension: int): index_type = MILVUS_INDEX_TYPE.upper() metric_type = MILVUS_METRIC_TYPE.upper() - log.info(f"Using Milvus index type: {index_type}, metric type: {metric_type}") + log.info(f'Using Milvus index type: {index_type}, metric type: {metric_type}') index_creation_params = {} - if index_type == "HNSW": + if index_type == 'HNSW': index_creation_params = { - "M": MILVUS_HNSW_M, - "efConstruction": MILVUS_HNSW_EFCONSTRUCTION, + 'M': MILVUS_HNSW_M, + 'efConstruction': MILVUS_HNSW_EFCONSTRUCTION, } - log.info(f"HNSW params: {index_creation_params}") - elif index_type == "IVF_FLAT": - index_creation_params = {"nlist": MILVUS_IVF_FLAT_NLIST} - log.info(f"IVF_FLAT params: {index_creation_params}") - elif index_type == "DISKANN": + log.info(f'HNSW params: {index_creation_params}') + elif index_type == 'IVF_FLAT': + index_creation_params = {'nlist': MILVUS_IVF_FLAT_NLIST} + log.info(f'IVF_FLAT params: {index_creation_params}') + elif index_type == 'DISKANN': index_creation_params = { - "max_degree": MILVUS_DISKANN_MAX_DEGREE, - "search_list_size": MILVUS_DISKANN_SEARCH_LIST_SIZE, + 'max_degree': MILVUS_DISKANN_MAX_DEGREE, + 'search_list_size': MILVUS_DISKANN_SEARCH_LIST_SIZE, } - log.info(f"DISKANN params: {index_creation_params}") - elif index_type in ["FLAT", "AUTOINDEX"]: - log.info(f"Using {index_type} index with no specific build-time params.") + log.info(f'DISKANN params: {index_creation_params}') + elif index_type in ['FLAT', 'AUTOINDEX']: + log.info(f'Using {index_type} index with no specific build-time params.') else: log.warning( f"Unsupported MILVUS_INDEX_TYPE: '{index_type}'. " - f"Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. " - f"Milvus will use its default for the collection if this type is not directly supported for index creation." + f'Supported types: HNSW, IVF_FLAT, DISKANN, FLAT, AUTOINDEX. ' + f'Milvus will use its default for the collection if this type is not directly supported for index creation.' ) # For unsupported types, pass the type directly to Milvus; it might handle it or use a default. # If Milvus errors out, the user needs to correct the MILVUS_INDEX_TYPE env var. index_params.add_index( - field_name="vector", + field_name='vector', index_type=index_type, metric_type=metric_type, params=index_creation_params, ) self.client.create_collection( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', schema=schema, index_params=index_params, ) @@ -170,17 +168,13 @@ def _create_collection(self, collection_name: str, dimension: int): def has_collection(self, collection_name: str) -> bool: # Check if the collection exists based on the collection name. - collection_name = collection_name.replace("-", "_") - return self.client.has_collection( - collection_name=f"{self.collection_prefix}_{collection_name}" - ) + collection_name = collection_name.replace('-', '_') + return self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}') def delete_collection(self, collection_name: str): # Delete the collection based on the collection name. - collection_name = collection_name.replace("-", "_") - return self.client.drop_collection( - collection_name=f"{self.collection_prefix}_{collection_name}" - ) + collection_name = collection_name.replace('-', '_') + return self.client.drop_collection(collection_name=f'{self.collection_prefix}_{collection_name}') def search( self, @@ -190,15 +184,15 @@ def search( limit: int = 10, ) -> Optional[SearchResult]: # Search for the nearest neighbor items based on the vectors and return 'limit' number of results. - collection_name = collection_name.replace("-", "_") + collection_name = collection_name.replace('-', '_') # For some index types like IVF_FLAT, search params like nprobe can be set. # Example: search_params = {"nprobe": 10} if using IVF_FLAT # For simplicity, not adding configurable search_params here, but could be extended. result = self.client.search( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', data=vectors, limit=limit, - output_fields=["data", "metadata"], + output_fields=['data', 'metadata'], # search_params=search_params # Potentially add later if needed ) return self._result_to_search_result(result) @@ -206,11 +200,9 @@ def search( 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("-", "_") + 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}" - ) + log.warning(f'Query attempted on non-existent collection: {self.collection_prefix}_{collection_name}') return None filter_expressions = [] @@ -220,9 +212,9 @@ def query(self, collection_name: str, filter: dict, limit: int = -1): else: filter_expressions.append(f'metadata["{key}"] == {value}') - filter_string = " && ".join(filter_expressions) + filter_string = ' && '.join(filter_expressions) - collection = Collection(f"{self.collection_prefix}_{collection_name}") + collection = Collection(f'{self.collection_prefix}_{collection_name}') collection.load() try: @@ -233,9 +225,9 @@ def query(self, collection_name: str, filter: dict, limit: int = -1): iterator = collection.query_iterator( expr=filter_string, output_fields=[ - "id", - "data", - "metadata", + 'id', + 'data', + 'metadata', ], limit=limit if limit > 0 else -1, ) @@ -248,7 +240,7 @@ def query(self, collection_name: str, filter: dict, limit: int = -1): break all_results.extend(batch) - log.debug(f"Total results from query: {len(all_results)}") + log.debug(f'Total results from query: {len(all_results)}') return self._result_to_get_result([all_results] if all_results else [[]]) except Exception as e: @@ -259,7 +251,7 @@ def query(self, collection_name: str, filter: dict, limit: int = -1): def get(self, collection_name: str) -> Optional[GetResult]: # Get all the items in the collection. This can be very resource-intensive for large collections. - collection_name = collection_name.replace("-", "_") + collection_name = collection_name.replace('-', '_') log.warning( f"Fetching ALL items from collection '{self.collection_prefix}_{collection_name}'. This might be slow for large collections." ) @@ -269,35 +261,25 @@ def get(self, collection_name: str) -> Optional[GetResult]: def insert(self, collection_name: str, items: list[VectorItem]): # Insert the items into the collection, if the collection does not exist, it will be created. - collection_name = collection_name.replace("-", "_") - if not self.client.has_collection( - collection_name=f"{self.collection_prefix}_{collection_name}" - ): - log.info( - f"Collection {self.collection_prefix}_{collection_name} does not exist. Creating now." - ) + collection_name = collection_name.replace('-', '_') + if not self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}'): + log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist. Creating now.') if not items: log.error( - f"Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension." - ) - raise ValueError( - "Cannot create Milvus collection without items to determine vector dimension." + f'Cannot create collection {self.collection_prefix}_{collection_name} without items to determine dimension.' ) - self._create_collection( - collection_name=collection_name, dimension=len(items[0]["vector"]) - ) + raise ValueError('Cannot create Milvus collection without items to determine vector dimension.') + self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector'])) - log.info( - f"Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}." - ) + log.info(f'Inserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.') return self.client.insert( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', data=[ { - "id": item["id"], - "vector": item["vector"], - "data": {"text": item["text"]}, - "metadata": process_metadata(item["metadata"]), + 'id': item['id'], + 'vector': item['vector'], + 'data': {'text': item['text']}, + 'metadata': process_metadata(item['metadata']), } for item in items ], @@ -305,35 +287,27 @@ def insert(self, collection_name: str, items: list[VectorItem]): def upsert(self, collection_name: str, items: list[VectorItem]): # Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created. - collection_name = collection_name.replace("-", "_") - if not self.client.has_collection( - collection_name=f"{self.collection_prefix}_{collection_name}" - ): - log.info( - f"Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now." - ) + collection_name = collection_name.replace('-', '_') + if not self.client.has_collection(collection_name=f'{self.collection_prefix}_{collection_name}'): + log.info(f'Collection {self.collection_prefix}_{collection_name} does not exist for upsert. Creating now.') if not items: log.error( - f"Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension." + f'Cannot create collection {self.collection_prefix}_{collection_name} for upsert without items to determine dimension.' ) raise ValueError( - "Cannot create Milvus collection for upsert without items to determine vector dimension." + 'Cannot create Milvus collection for upsert without items to determine vector dimension.' ) - self._create_collection( - collection_name=collection_name, dimension=len(items[0]["vector"]) - ) + self._create_collection(collection_name=collection_name, dimension=len(items[0]['vector'])) - log.info( - f"Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}." - ) + log.info(f'Upserting {len(items)} items into collection {self.collection_prefix}_{collection_name}.') return self.client.upsert( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', data=[ { - "id": item["id"], - "vector": item["vector"], - "data": {"text": item["text"]}, - "metadata": process_metadata(item["metadata"]), + 'id': item['id'], + 'vector': item['vector'], + 'data': {'text': item['text']}, + 'metadata': process_metadata(item['metadata']), } for item in items ], @@ -346,46 +320,35 @@ def delete( filter: Optional[dict] = None, ): # Delete the items from the collection based on the ids or filter. - collection_name = collection_name.replace("-", "_") + collection_name = collection_name.replace('-', '_') if not self.has_collection(collection_name): - log.warning( - f"Delete attempted on non-existent collection: {self.collection_prefix}_{collection_name}" - ) + log.warning(f'Delete attempted on non-existent collection: {self.collection_prefix}_{collection_name}') return None if ids: - log.info( - f"Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}" - ) + log.info(f'Deleting items by IDs from {self.collection_prefix}_{collection_name}. IDs: {ids}') return self.client.delete( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', ids=ids, ) elif filter: - filter_string = " && ".join( - [ - f'metadata["{key}"] == {json.dumps(value)}' - for key, value in filter.items() - ] - ) + filter_string = ' && '.join([f'metadata["{key}"] == {json.dumps(value)}' for key, value in filter.items()]) log.info( - f"Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}" + f'Deleting items by filter from {self.collection_prefix}_{collection_name}. Filter: {filter_string}' ) return self.client.delete( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', filter=filter_string, ) else: log.warning( - f"Delete operation on {self.collection_prefix}_{collection_name} called without IDs or filter. No action taken." + f'Delete operation on {self.collection_prefix}_{collection_name} called without IDs or filter. No action taken.' ) return None def reset(self): # Resets the database. This will delete all collections and item entries that match the prefix. - log.warning( - f"Resetting Milvus: Deleting all collections with prefix '{self.collection_prefix}'." - ) + log.warning(f"Resetting Milvus: Deleting all collections with prefix '{self.collection_prefix}'.") collection_names = self.client.list_collections() deleted_collections = [] for collection_name_full in collection_names: @@ -393,7 +356,7 @@ def reset(self): try: self.client.drop_collection(collection_name=collection_name_full) deleted_collections.append(collection_name_full) - log.info(f"Deleted collection: {collection_name_full}") + log.info(f'Deleted collection: {collection_name_full}') except Exception as e: - log.error(f"Error deleting collection {collection_name_full}: {e}") - log.info(f"Milvus reset complete. Deleted collections: {deleted_collections}") + log.error(f'Error deleting collection {collection_name_full}: {e}') + log.info(f'Milvus reset complete. Deleted collections: {deleted_collections}') diff --git a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py index 0ecbac15d2..93b4a8cbc4 100644 --- a/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/milvus_multitenancy.py @@ -33,26 +33,26 @@ log = logging.getLogger(__name__) -RESOURCE_ID_FIELD = "resource_id" +RESOURCE_ID_FIELD = 'resource_id' class MilvusClient(VectorDBBase): def __init__(self): # Milvus collection names can only contain numbers, letters, and underscores. - self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace("-", "_") + self.collection_prefix = MILVUS_COLLECTION_PREFIX.replace('-', '_') connections.connect( - alias="default", + alias='default', uri=MILVUS_URI, token=MILVUS_TOKEN, db_name=MILVUS_DB, ) # Main collection types for multi-tenancy - self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories" - self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge" - self.FILE_COLLECTION = f"{self.collection_prefix}_files" - self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web_search" - self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash_based" + self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories' + self.KNOWLEDGE_COLLECTION = f'{self.collection_prefix}_knowledge' + self.FILE_COLLECTION = f'{self.collection_prefix}_files' + self.WEB_SEARCH_COLLECTION = f'{self.collection_prefix}_web_search' + self.HASH_BASED_COLLECTION = f'{self.collection_prefix}_hash_based' self.shared_collections = [ self.MEMORY_COLLECTION, self.KNOWLEDGE_COLLECTION, @@ -74,15 +74,13 @@ def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, st """ resource_id = collection_name - if collection_name.startswith("user-memory-"): + if collection_name.startswith('user-memory-'): return self.MEMORY_COLLECTION, resource_id - elif collection_name.startswith("file-"): + elif collection_name.startswith('file-'): return self.FILE_COLLECTION, resource_id - elif collection_name.startswith("web-search-"): + elif collection_name.startswith('web-search-'): return self.WEB_SEARCH_COLLECTION, resource_id - elif len(collection_name) == 63 and all( - c in "0123456789abcdef" for c in collection_name - ): + elif len(collection_name) == 63 and all(c in '0123456789abcdef' for c in collection_name): return self.HASH_BASED_COLLECTION, resource_id else: return self.KNOWLEDGE_COLLECTION, resource_id @@ -90,36 +88,36 @@ def _get_collection_and_resource_id(self, collection_name: str) -> Tuple[str, st def _create_shared_collection(self, mt_collection_name: str, dimension: int): fields = [ FieldSchema( - name="id", + 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='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") + schema = CollectionSchema(fields, 'Shared collection for multi-tenancy') collection = Collection(mt_collection_name, schema) index_params = { - "metric_type": MILVUS_METRIC_TYPE, - "index_type": MILVUS_INDEX_TYPE, - "params": {}, + 'metric_type': MILVUS_METRIC_TYPE, + 'index_type': MILVUS_INDEX_TYPE, + 'params': {}, } - if MILVUS_INDEX_TYPE == "HNSW": - index_params["params"] = { - "M": MILVUS_HNSW_M, - "efConstruction": MILVUS_HNSW_EFCONSTRUCTION, + if MILVUS_INDEX_TYPE == 'HNSW': + index_params['params'] = { + 'M': MILVUS_HNSW_M, + 'efConstruction': MILVUS_HNSW_EFCONSTRUCTION, } - elif MILVUS_INDEX_TYPE == "IVF_FLAT": - index_params["params"] = {"nlist": MILVUS_IVF_FLAT_NLIST} + elif MILVUS_INDEX_TYPE == 'IVF_FLAT': + index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST} - collection.create_index("vector", index_params) + collection.create_index('vector', index_params) collection.create_index(RESOURCE_ID_FIELD) - log.info(f"Created shared collection: {mt_collection_name}") + log.info(f'Created shared collection: {mt_collection_name}') return collection def _ensure_collection(self, mt_collection_name: str, dimension: int): @@ -127,9 +125,7 @@ def _ensure_collection(self, mt_collection_name: str, dimension: int): 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 - ) + mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) if not utility.has_collection(mt_collection): return False @@ -141,19 +137,17 @@ def has_collection(self, collection_name: str) -> bool: def upsert(self, collection_name: str, items: List[VectorItem]): if not items: return - mt_collection, resource_id = self._get_collection_and_resource_id( - collection_name - ) - dimension = len(items[0]["vector"]) + mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) + dimension = len(items[0]['vector']) self._ensure_collection(mt_collection, dimension) collection = Collection(mt_collection) entities = [ { - "id": item["id"], - "vector": item["vector"], - "text": item["text"], - "metadata": item["metadata"], + 'id': item['id'], + 'vector': item['vector'], + 'text': item['text'], + 'metadata': item['metadata'], RESOURCE_ID_FIELD: resource_id, } for item in items @@ -170,41 +164,37 @@ def search( if not vectors: return None - mt_collection, resource_id = self._get_collection_and_resource_id( - collection_name - ) + mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) if not utility.has_collection(mt_collection): return None collection = Collection(mt_collection) collection.load() - search_params = {"metric_type": MILVUS_METRIC_TYPE, "params": {}} + search_params = {'metric_type': MILVUS_METRIC_TYPE, 'params': {}} results = collection.search( data=vectors, - anns_field="vector", + anns_field='vector', param=search_params, limit=limit, expr=f"{RESOURCE_ID_FIELD} == '{resource_id}'", - output_fields=["id", "text", "metadata"], + output_fields=['id', 'text', 'metadata'], ) ids, documents, metadatas, distances = [], [], [], [] 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_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) ids.append(batch_ids) documents.append(batch_docs) metadatas.append(batch_metadatas) distances.append(batch_dists) - return SearchResult( - ids=ids, documents=documents, metadatas=metadatas, distances=distances - ) + return SearchResult(ids=ids, documents=documents, metadatas=metadatas, distances=distances) def delete( self, @@ -212,9 +202,7 @@ def delete( ids: Optional[List[str]] = None, filter: Optional[Dict[str, Any]] = None, ): - mt_collection, resource_id = self._get_collection_and_resource_id( - collection_name - ) + mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) if not utility.has_collection(mt_collection): return @@ -224,14 +212,14 @@ def delete( expr = [f"{RESOURCE_ID_FIELD} == '{resource_id}'"] if ids: # Milvus expects a string list for 'in' operator - id_list_str = ", ".join([f"'{id_val}'" for id_val in ids]) - expr.append(f"id in [{id_list_str}]") + id_list_str = ', '.join([f"'{id_val}'" for id_val in ids]) + expr.append(f'id in [{id_list_str}]') if filter: for key, value in filter.items(): expr.append(f"metadata['{key}'] == '{value}'") - collection.delete(" and ".join(expr)) + collection.delete(' and '.join(expr)) def reset(self): for collection_name in self.shared_collections: @@ -239,21 +227,15 @@ def reset(self): utility.drop_collection(collection_name) def delete_collection(self, collection_name: str): - mt_collection, resource_id = self._get_collection_and_resource_id( - collection_name - ) + mt_collection, resource_id = self._get_collection_and_resource_id(collection_name) if not utility.has_collection(mt_collection): return collection = Collection(mt_collection) collection.delete(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 - ) + 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) if not utility.has_collection(mt_collection): return None @@ -269,8 +251,8 @@ def query( expr.append(f"metadata['{key}'] == {value}") iterator = collection.query_iterator( - expr=" and ".join(expr), - output_fields=["id", "text", "metadata"], + expr=' and '.join(expr), + output_fields=['id', 'text', 'metadata'], limit=limit if limit else -1, ) @@ -282,9 +264,9 @@ def query( break all_results.extend(batch) - ids = [res["id"] for res in all_results] - documents = [res["text"] for res in all_results] - metadatas = [res["metadata"] for res in all_results] + ids = [res['id'] for res in all_results] + documents = [res['text'] for res in all_results] + metadatas = [res['metadata'] for res in all_results] return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) diff --git a/backend/open_webui/retrieval/vector/dbs/opengauss.py b/backend/open_webui/retrieval/vector/dbs/opengauss.py index 679847a1d4..ac97cf01fa 100644 --- a/backend/open_webui/retrieval/vector/dbs/opengauss.py +++ b/backend/open_webui/retrieval/vector/dbs/opengauss.py @@ -36,17 +36,15 @@ class OpenGaussDialect(PGDialect_psycopg2): - name = "opengauss" + name = 'opengauss' def _get_server_version_info(self, connection): try: - version = connection.exec_driver_sql("SELECT version()").scalar() + version = connection.exec_driver_sql('SELECT version()').scalar() if not version: return (9, 0, 0) - match = re.search( - r"openGauss\s+(\d+)\.(\d+)\.(\d+)(?:-\w+)?", version, re.IGNORECASE - ) + match = re.search(r'openGauss\s+(\d+)\.(\d+)\.(\d+)(?:-\w+)?', version, re.IGNORECASE) if match: return (int(match.group(1)), int(match.group(2)), int(match.group(3))) @@ -56,7 +54,7 @@ def _get_server_version_info(self, connection): # Register dialect -registry.register("opengauss", __name__, "OpenGaussDialect") +registry.register('opengauss', __name__, 'OpenGaussDialect') from open_webui.retrieval.vector.utils import process_metadata from open_webui.retrieval.vector.main import ( @@ -80,11 +78,11 @@ def _get_server_version_info(self, connection): Base = declarative_base() log = logging.getLogger(__name__) -log.setLevel(SRC_LOG_LEVELS["RAG"]) +log.setLevel(SRC_LOG_LEVELS['RAG']) class DocumentChunk(Base): - __tablename__ = "document_chunk" + __tablename__ = 'document_chunk' id = Column(Text, primary_key=True) vector = Column(Vector(dim=VECTOR_LENGTH), nullable=True) @@ -100,26 +98,24 @@ def __init__(self) -> None: self.session = ScopedSession else: - engine_kwargs = {"pool_pre_ping": True, "dialect": OpenGaussDialect()} + engine_kwargs = {'pool_pre_ping': True, 'dialect': OpenGaussDialect()} if isinstance(OPENGAUSS_POOL_SIZE, int) and OPENGAUSS_POOL_SIZE > 0: engine_kwargs.update( { - "pool_size": OPENGAUSS_POOL_SIZE, - "max_overflow": OPENGAUSS_POOL_MAX_OVERFLOW, - "pool_timeout": OPENGAUSS_POOL_TIMEOUT, - "pool_recycle": OPENGAUSS_POOL_RECYCLE, - "poolclass": QueuePool, + 'pool_size': OPENGAUSS_POOL_SIZE, + 'max_overflow': OPENGAUSS_POOL_MAX_OVERFLOW, + 'pool_timeout': OPENGAUSS_POOL_TIMEOUT, + 'pool_recycle': OPENGAUSS_POOL_RECYCLE, + 'poolclass': QueuePool, } ) else: - engine_kwargs["poolclass"] = NullPool + engine_kwargs['poolclass'] = NullPool engine = create_engine(OPENGAUSS_DB_URL, **engine_kwargs) - SessionLocal = sessionmaker( - autocommit=False, autoflush=False, bind=engine, expire_on_commit=False - ) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) self.session = scoped_session(SessionLocal) try: @@ -128,47 +124,42 @@ def __init__(self) -> None: self.session.execute( text( - "CREATE INDEX IF NOT EXISTS idx_document_chunk_vector " - "ON document_chunk USING ivfflat (vector vector_cosine_ops) WITH (lists = 100);" + 'CREATE INDEX IF NOT EXISTS idx_document_chunk_vector ' + 'ON document_chunk USING ivfflat (vector vector_cosine_ops) WITH (lists = 100);' ) ) self.session.execute( text( - "CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name " - "ON document_chunk (collection_name);" + 'CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name ON document_chunk (collection_name);' ) ) self.session.commit() - log.info("OpenGauss vector database initialization completed.") + log.info('OpenGauss vector database initialization completed.') except Exception as e: self.session.rollback() - log.exception(f"OpenGauss Initialization failed.: {e}") + log.exception(f'OpenGauss Initialization failed.: {e}') raise def check_vector_length(self) -> None: metadata = MetaData() try: - document_chunk_table = Table( - "document_chunk", metadata, autoload_with=self.session.bind - ) + document_chunk_table = Table('document_chunk', metadata, autoload_with=self.session.bind) except NoSuchTableError: return - if "vector" in document_chunk_table.columns: - vector_column = document_chunk_table.columns["vector"] + if 'vector' in document_chunk_table.columns: + vector_column = document_chunk_table.columns['vector'] vector_type = vector_column.type if isinstance(vector_type, Vector): db_vector_length = vector_type.dim if db_vector_length != VECTOR_LENGTH: raise Exception( - f"Vector dimension mismatch: configured {VECTOR_LENGTH} vs. {db_vector_length} in the database." + f'Vector dimension mismatch: configured {VECTOR_LENGTH} vs. {db_vector_length} in the database.' ) else: raise Exception("The 'vector' column type is not Vector.") else: - raise Exception( - "The 'vector' column does not exist in the 'document_chunk' table." - ) + raise Exception("The 'vector' column does not exist in the 'document_chunk' table.") def adjust_vector_length(self, vector: List[float]) -> List[float]: current_length = len(vector) @@ -182,55 +173,47 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: try: new_items = [] for item in items: - vector = self.adjust_vector_length(item["vector"]) + vector = self.adjust_vector_length(item['vector']) new_chunk = DocumentChunk( - id=item["id"], + id=item['id'], vector=vector, collection_name=collection_name, - text=item["text"], - vmetadata=process_metadata(item["metadata"]), + text=item['text'], + vmetadata=process_metadata(item['metadata']), ) new_items.append(new_chunk) self.session.bulk_save_objects(new_items) self.session.commit() - log.info( - f"Inserting {len(new_items)} items into collection '{collection_name}'." - ) + log.info(f"Inserting {len(new_items)} items into collection '{collection_name}'.") except Exception as e: self.session.rollback() - log.exception(f"Failed to insert data: {e}") + log.exception(f'Failed to insert data: {e}') raise def upsert(self, collection_name: str, items: List[VectorItem]) -> None: try: for item in items: - vector = self.adjust_vector_length(item["vector"]) - existing = ( - self.session.query(DocumentChunk) - .filter(DocumentChunk.id == item["id"]) - .first() - ) + vector = self.adjust_vector_length(item['vector']) + existing = self.session.query(DocumentChunk).filter(DocumentChunk.id == item['id']).first() if existing: existing.vector = vector - existing.text = item["text"] - existing.vmetadata = process_metadata(item["metadata"]) + existing.text = item['text'] + existing.vmetadata = process_metadata(item['metadata']) existing.collection_name = collection_name else: new_chunk = DocumentChunk( - id=item["id"], + id=item['id'], vector=vector, collection_name=collection_name, - text=item["text"], - vmetadata=process_metadata(item["metadata"]), + text=item['text'], + vmetadata=process_metadata(item['metadata']), ) self.session.add(new_chunk) self.session.commit() - log.info( - f"Inserting/updating {len(items)} items in collection '{collection_name}'." - ) + log.info(f"Inserting/updating {len(items)} items in collection '{collection_name}'.") except Exception as e: self.session.rollback() - log.exception(f"Failed to insert or update data.: {e}") + log.exception(f'Failed to insert or update data.: {e}') raise def search( @@ -250,35 +233,29 @@ def search( def vector_expr(vector): return cast(array(vector), Vector(VECTOR_LENGTH)) - qid_col = column("qid", Integer) - q_vector_col = column("q_vector", Vector(VECTOR_LENGTH)) + qid_col = column('qid', Integer) + q_vector_col = column('q_vector', Vector(VECTOR_LENGTH)) query_vectors = ( values(qid_col, q_vector_col) - .data( - [(idx, vector_expr(vector)) for idx, vector in enumerate(vectors)] - ) - .alias("query_vectors") + .data([(idx, vector_expr(vector)) for idx, vector in enumerate(vectors)]) + .alias('query_vectors') ) result_fields = [ DocumentChunk.id, DocumentChunk.text, DocumentChunk.vmetadata, - (DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label( - "distance" - ), + (DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label('distance'), ] subq = ( select(*result_fields) .where(DocumentChunk.collection_name == collection_name) - .order_by( - DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector) - ) + .order_by(DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)) ) if limit is not None: subq = subq.limit(limit) - subq = subq.lateral("result") + subq = subq.lateral('result') stmt = ( select( @@ -309,21 +286,15 @@ def vector_expr(vector): metadatas[qid].append(row.vmetadata) self.session.rollback() - return SearchResult( - ids=ids, distances=distances, documents=documents, metadatas=metadatas - ) + return SearchResult(ids=ids, distances=distances, documents=documents, metadatas=metadatas) except Exception as e: self.session.rollback() - log.exception(f"Vector search failed: {e}") + log.exception(f'Vector search failed: {e}') return None - def query( - self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]: try: - query = self.session.query(DocumentChunk).filter( - DocumentChunk.collection_name == collection_name - ) + query = self.session.query(DocumentChunk).filter(DocumentChunk.collection_name == collection_name) for key, value in filter.items(): query = query.filter(DocumentChunk.vmetadata[key].astext == str(value)) @@ -344,16 +315,12 @@ def query( return GetResult(ids=ids, documents=documents, metadatas=metadatas) except Exception as e: self.session.rollback() - log.exception(f"Conditional query failed: {e}") + log.exception(f'Conditional query failed: {e}') return None - def get( - self, collection_name: str, limit: Optional[int] = None - ) -> Optional[GetResult]: + def get(self, collection_name: str, limit: Optional[int] = None) -> Optional[GetResult]: try: - query = self.session.query(DocumentChunk).filter( - DocumentChunk.collection_name == collection_name - ) + query = self.session.query(DocumentChunk).filter(DocumentChunk.collection_name == collection_name) if limit is not None: query = query.limit(limit) @@ -370,7 +337,7 @@ def get( return GetResult(ids=ids, documents=documents, metadatas=metadatas) except Exception as e: self.session.rollback() - log.exception(f"Failed to retrieve data: {e}") + log.exception(f'Failed to retrieve data: {e}') return None def delete( @@ -380,32 +347,28 @@ def delete( filter: Optional[Dict[str, Any]] = None, ) -> None: try: - query = self.session.query(DocumentChunk).filter( - DocumentChunk.collection_name == collection_name - ) + query = self.session.query(DocumentChunk).filter(DocumentChunk.collection_name == collection_name) if ids: query = query.filter(DocumentChunk.id.in_(ids)) if filter: for key, value in filter.items(): - query = query.filter( - DocumentChunk.vmetadata[key].astext == str(value) - ) + query = query.filter(DocumentChunk.vmetadata[key].astext == str(value)) deleted = query.delete(synchronize_session=False) self.session.commit() log.info(f"Deleted {deleted} items from collection '{collection_name}'") except Exception as e: self.session.rollback() - log.exception(f"Failed to delete data: {e}") + log.exception(f'Failed to delete data: {e}') raise def reset(self) -> None: try: deleted = self.session.query(DocumentChunk).delete() self.session.commit() - log.info(f"Reset completed. Deleted {deleted} items") + log.info(f'Reset completed. Deleted {deleted} items') except Exception as e: self.session.rollback() - log.exception(f"Reset failed: {e}") + log.exception(f'Reset failed: {e}') raise def close(self) -> None: @@ -414,16 +377,14 @@ def close(self) -> None: def has_collection(self, collection_name: str) -> bool: try: exists = ( - self.session.query(DocumentChunk) - .filter(DocumentChunk.collection_name == collection_name) - .first() + self.session.query(DocumentChunk).filter(DocumentChunk.collection_name == collection_name).first() is not None ) self.session.rollback() return exists except Exception as e: self.session.rollback() - log.exception(f"Failed to check collection existence: {e}") + log.exception(f'Failed to check collection existence: {e}') return False def delete_collection(self, collection_name: str) -> None: diff --git a/backend/open_webui/retrieval/vector/dbs/opensearch.py b/backend/open_webui/retrieval/vector/dbs/opensearch.py index 3ad82d7442..a08dca7865 100644 --- a/backend/open_webui/retrieval/vector/dbs/opensearch.py +++ b/backend/open_webui/retrieval/vector/dbs/opensearch.py @@ -24,7 +24,7 @@ class OpenSearchClient(VectorDBBase): def __init__(self): - self.index_prefix = "open_webui" + self.index_prefix = 'open_webui' self.client = OpenSearch( hosts=[OPENSEARCH_URI], use_ssl=OPENSEARCH_SSL, @@ -33,25 +33,25 @@ def __init__(self): ) def _get_index_name(self, collection_name: str) -> str: - return f"{self.index_prefix}_{collection_name}" + return f'{self.index_prefix}_{collection_name}' def _result_to_get_result(self, result) -> GetResult: - if not result["hits"]["hits"]: + if not result['hits']['hits']: return None ids = [] documents = [] metadatas = [] - for hit in result["hits"]["hits"]: - ids.append(hit["_id"]) - documents.append(hit["_source"].get("text")) - metadatas.append(hit["_source"].get("metadata")) + for hit in result['hits']['hits']: + ids.append(hit['_id']) + documents.append(hit['_source'].get('text')) + metadatas.append(hit['_source'].get('metadata')) return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) def _result_to_search_result(self, result) -> SearchResult: - if not result["hits"]["hits"]: + if not result['hits']['hits']: return None ids = [] @@ -59,11 +59,11 @@ def _result_to_search_result(self, result) -> SearchResult: documents = [] metadatas = [] - for hit in result["hits"]["hits"]: - ids.append(hit["_id"]) - distances.append(hit["_score"]) - documents.append(hit["_source"].get("text")) - metadatas.append(hit["_source"].get("metadata")) + for hit in result['hits']['hits']: + ids.append(hit['_id']) + distances.append(hit['_score']) + documents.append(hit['_source'].get('text')) + metadatas.append(hit['_source'].get('metadata')) return SearchResult( ids=[ids], @@ -74,33 +74,31 @@ def _result_to_search_result(self, result) -> SearchResult: def _create_index(self, collection_name: str, dimension: int): body = { - "settings": {"index": {"knn": True}}, - "mappings": { - "properties": { - "id": {"type": "keyword"}, - "vector": { - "type": "knn_vector", - "dimension": dimension, # Adjust based on your vector dimensions - "index": True, - "similarity": "faiss", - "method": { - "name": "hnsw", - "space_type": "innerproduct", # Use inner product to approximate cosine similarity - "engine": "faiss", - "parameters": { - "ef_construction": 128, - "m": 16, + 'settings': {'index': {'knn': True}}, + 'mappings': { + 'properties': { + 'id': {'type': 'keyword'}, + 'vector': { + 'type': 'knn_vector', + 'dimension': dimension, # Adjust based on your vector dimensions + 'index': True, + 'similarity': 'faiss', + 'method': { + 'name': 'hnsw', + 'space_type': 'innerproduct', # Use inner product to approximate cosine similarity + 'engine': 'faiss', + 'parameters': { + 'ef_construction': 128, + 'm': 16, }, }, }, - "text": {"type": "text"}, - "metadata": {"type": "object"}, + 'text': {'type': 'text'}, + 'metadata': {'type': 'object'}, } }, } - self.client.indices.create( - index=self._get_index_name(collection_name), body=body - ) + self.client.indices.create(index=self._get_index_name(collection_name), body=body) def _create_batches(self, items: list[VectorItem], batch_size=100): for i in range(0, len(items), batch_size): @@ -128,46 +126,40 @@ def search( return None query = { - "size": limit, - "_source": ["text", "metadata"], - "query": { - "script_score": { - "query": {"match_all": {}}, - "script": { - "source": "(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0", - "params": { - "field": "vector", - "query_value": vectors[0], + 'size': limit, + '_source': ['text', 'metadata'], + 'query': { + 'script_score': { + 'query': {'match_all': {}}, + 'script': { + 'source': '(cosineSimilarity(params.query_value, doc[params.field]) + 1.0) / 2.0', + 'params': { + 'field': 'vector', + 'query_value': vectors[0], }, # Assuming single query vector }, } }, } - result = self.client.search( - index=self._get_index_name(collection_name), body=query - ) + result = self.client.search(index=self._get_index_name(collection_name), body=query) return self._result_to_search_result(result) except Exception as e: return None - def query( - self, collection_name: str, filter: dict, limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) -> Optional[GetResult]: if not self.has_collection(collection_name): return None query_body = { - "query": {"bool": {"filter": []}}, - "_source": ["text", "metadata"], + 'query': {'bool': {'filter': []}}, + '_source': ['text', 'metadata'], } for field, value in filter.items(): - query_body["query"]["bool"]["filter"].append( - {"term": {"metadata." + str(field) + ".keyword": value}} - ) + query_body['query']['bool']['filter'].append({'term': {'metadata.' + str(field) + '.keyword': value}}) size = limit if limit else 10000 @@ -188,28 +180,24 @@ def _create_index_if_not_exists(self, collection_name: str, dimension: int): self._create_index(collection_name, dimension) def get(self, collection_name: str) -> Optional[GetResult]: - query = {"query": {"match_all": {}}, "_source": ["text", "metadata"]} + query = {'query': {'match_all': {}}, '_source': ['text', 'metadata']} - result = self.client.search( - index=self._get_index_name(collection_name), body=query - ) + result = self.client.search(index=self._get_index_name(collection_name), body=query) return self._result_to_get_result(result) def insert(self, collection_name: str, items: list[VectorItem]): - self._create_index_if_not_exists( - collection_name=collection_name, dimension=len(items[0]["vector"]) - ) + self._create_index_if_not_exists(collection_name=collection_name, dimension=len(items[0]['vector'])) for batch in self._create_batches(items): actions = [ { - "_op_type": "index", - "_index": self._get_index_name(collection_name), - "_id": item["id"], - "_source": { - "vector": item["vector"], - "text": item["text"], - "metadata": process_metadata(item["metadata"]), + '_op_type': 'index', + '_index': self._get_index_name(collection_name), + '_id': item['id'], + '_source': { + 'vector': item['vector'], + 'text': item['text'], + 'metadata': process_metadata(item['metadata']), }, } for item in batch @@ -218,22 +206,20 @@ def insert(self, collection_name: str, items: list[VectorItem]): self.client.indices.refresh(index=self._get_index_name(collection_name)) def upsert(self, collection_name: str, items: list[VectorItem]): - self._create_index_if_not_exists( - collection_name=collection_name, dimension=len(items[0]["vector"]) - ) + self._create_index_if_not_exists(collection_name=collection_name, dimension=len(items[0]['vector'])) for batch in self._create_batches(items): actions = [ { - "_op_type": "update", - "_index": self._get_index_name(collection_name), - "_id": item["id"], - "doc": { - "vector": item["vector"], - "text": item["text"], - "metadata": process_metadata(item["metadata"]), + '_op_type': 'update', + '_index': self._get_index_name(collection_name), + '_id': item['id'], + 'doc': { + 'vector': item['vector'], + 'text': item['text'], + 'metadata': process_metadata(item['metadata']), }, - "doc_as_upsert": True, + 'doc_as_upsert': True, } for item in batch ] @@ -249,27 +235,23 @@ def delete( if ids: actions = [ { - "_op_type": "delete", - "_index": self._get_index_name(collection_name), - "_id": id, + '_op_type': 'delete', + '_index': self._get_index_name(collection_name), + '_id': id, } for id in ids ] bulk(self.client, actions) elif filter: query_body = { - "query": {"bool": {"filter": []}}, + 'query': {'bool': {'filter': []}}, } for field, value in filter.items(): - query_body["query"]["bool"]["filter"].append( - {"term": {"metadata." + str(field) + ".keyword": value}} - ) - self.client.delete_by_query( - index=self._get_index_name(collection_name), body=query_body - ) + query_body['query']['bool']['filter'].append({'term': {'metadata.' + str(field) + '.keyword': value}}) + self.client.delete_by_query(index=self._get_index_name(collection_name), body=query_body) self.client.indices.refresh(index=self._get_index_name(collection_name)) def reset(self): - indices = self.client.indices.get(index=f"{self.index_prefix}_*") + indices = self.client.indices.get(index=f'{self.index_prefix}_*') for index in indices: self.client.indices.delete(index=index) diff --git a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py index 10428de384..9a5bd638d9 100644 --- a/backend/open_webui/retrieval/vector/dbs/oracle23ai.py +++ b/backend/open_webui/retrieval/vector/dbs/oracle23ai.py @@ -94,15 +94,15 @@ def __init__(self) -> None: self._create_dbcs_pool() dsn = ORACLE_DB_DSN - log.info(f"Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]") + log.info(f'Creating Connection Pool [{ORACLE_DB_USER}:**@{dsn}]') with self.get_connection() as connection: - log.info(f"Connection version: {connection.version}") + log.info(f'Connection version: {connection.version}') self._initialize_database(connection) - log.info("Oracle Vector Search initialization complete.") + log.info('Oracle Vector Search initialization complete.') except Exception as e: - log.exception(f"Error during Oracle Vector Search initialization: {e}") + log.exception(f'Error during Oracle Vector Search initialization: {e}') raise def _create_adb_pool(self) -> None: @@ -122,7 +122,7 @@ def _create_adb_pool(self) -> None: wallet_location=ORACLE_WALLET_DIR, wallet_password=ORACLE_WALLET_PASSWORD, ) - log.info("Created ADB connection pool with wallet authentication.") + log.info('Created ADB connection pool with wallet authentication.') def _create_dbcs_pool(self) -> None: """ @@ -138,7 +138,7 @@ def _create_dbcs_pool(self) -> None: max=ORACLE_DB_POOL_MAX, increment=ORACLE_DB_POOL_INCREMENT, ) - log.info("Created DB connection pool with basic authentication.") + log.info('Created DB connection pool with basic authentication.') def get_connection(self): """ @@ -155,13 +155,11 @@ def get_connection(self): return connection except oracledb.DatabaseError as e: (error_obj,) = e.args - log.exception( - f"Connection attempt {attempt + 1} failed: {error_obj.message}" - ) + log.exception(f'Connection attempt {attempt + 1} failed: {error_obj.message}') if attempt < max_retries - 1: wait_time = 2**attempt - log.info(f"Retrying in {wait_time} seconds...") + log.info(f'Retrying in {wait_time} seconds...') time.sleep(wait_time) else: raise @@ -177,30 +175,30 @@ def start_health_monitor(self, interval_seconds: int = 60): def _monitor(): while True: try: - log.info("[HealthCheck] Running periodic DB health check...") + log.info('[HealthCheck] Running periodic DB health check...') self.ensure_connection() - log.info("[HealthCheck] Connection is healthy.") + log.info('[HealthCheck] Connection is healthy.') except Exception as e: - log.exception(f"[HealthCheck] Connection health check failed: {e}") + log.exception(f'[HealthCheck] Connection health check failed: {e}') time.sleep(interval_seconds) thread = threading.Thread(target=_monitor, daemon=True) thread.start() - log.info(f"Started DB health monitor every {interval_seconds} seconds.") + log.info(f'Started DB health monitor every {interval_seconds} seconds.') def _reconnect_pool(self): """ Attempt to reinitialize the connection pool if it's been closed or broken. """ try: - log.info("Attempting to reinitialize the Oracle connection pool...") + log.info('Attempting to reinitialize the Oracle connection pool...') # Close existing pool if it exists if self.pool: try: self.pool.close() except Exception as close_error: - log.warning(f"Error closing existing pool: {close_error}") + log.warning(f'Error closing existing pool: {close_error}') # Re-create the appropriate connection pool based on DB type if ORACLE_DB_USE_WALLET: @@ -208,9 +206,9 @@ def _reconnect_pool(self): else: # DBCS self._create_dbcs_pool() - log.info("Connection pool reinitialized.") + log.info('Connection pool reinitialized.') except Exception as e: - log.exception(f"Failed to reinitialize the connection pool: {e}") + log.exception(f'Failed to reinitialize the connection pool: {e}') raise def ensure_connection(self): @@ -220,11 +218,9 @@ def ensure_connection(self): try: with self.get_connection() as connection: with connection.cursor() as cursor: - cursor.execute("SELECT 1 FROM dual") + cursor.execute('SELECT 1 FROM dual') except Exception as e: - log.exception( - f"Connection check failed: {e}, attempting to reconnect pool..." - ) + log.exception(f'Connection check failed: {e}, attempting to reconnect pool...') self._reconnect_pool() def _output_type_handler(self, cursor, metadata): @@ -239,9 +235,7 @@ def _output_type_handler(self, cursor, metadata): A variable with appropriate conversion for vector types """ if metadata.type_code is oracledb.DB_TYPE_VECTOR: - return cursor.var( - metadata.type_code, arraysize=cursor.arraysize, outconverter=list - ) + return cursor.var(metadata.type_code, arraysize=cursor.arraysize, outconverter=list) def _initialize_database(self, connection) -> None: """ @@ -257,8 +251,9 @@ def _initialize_database(self, connection) -> None: """ with connection.cursor() as cursor: try: - log.info("Creating Table document_chunk") - cursor.execute(""" + log.info('Creating Table document_chunk') + cursor.execute( + """ BEGIN EXECUTE IMMEDIATE ' CREATE TABLE IF NOT EXISTS document_chunk ( @@ -275,10 +270,12 @@ def _initialize_database(self, connection) -> None: RAISE; END IF; END; - """) + """ + ) - log.info("Creating Index document_chunk_collection_name_idx") - cursor.execute(""" + log.info('Creating Index document_chunk_collection_name_idx') + cursor.execute( + """ BEGIN EXECUTE IMMEDIATE ' CREATE INDEX IF NOT EXISTS document_chunk_collection_name_idx @@ -290,10 +287,12 @@ def _initialize_database(self, connection) -> None: RAISE; END IF; END; - """) + """ + ) - log.info("Creating VECTOR INDEX document_chunk_vector_ivf_idx") - cursor.execute(""" + log.info('Creating VECTOR INDEX document_chunk_vector_ivf_idx') + cursor.execute( + """ BEGIN EXECUTE IMMEDIATE ' CREATE VECTOR INDEX IF NOT EXISTS document_chunk_vector_ivf_idx @@ -309,14 +308,15 @@ def _initialize_database(self, connection) -> None: RAISE; END IF; END; - """) + """ + ) connection.commit() - log.info("Database initialization completed successfully.") + log.info('Database initialization completed successfully.') except Exception as e: connection.rollback() - log.exception(f"Error during database initialization: {e}") + log.exception(f'Error during database initialization: {e}') raise def check_vector_length(self) -> None: @@ -338,7 +338,7 @@ def _vector_to_blob(self, vector: List[float]) -> bytes: Returns: bytes: The vector in Oracle BLOB format """ - return array.array("f", vector) + return array.array('f', vector) def adjust_vector_length(self, vector: List[float]) -> List[float]: """ @@ -367,7 +367,7 @@ def _decimal_handler(self, obj): """ if isinstance(obj, Decimal): return float(obj) - raise TypeError(f"{obj} is not JSON serializable") + raise TypeError(f'{obj} is not JSON serializable') def _metadata_to_json(self, metadata: Dict) -> str: """ @@ -379,7 +379,7 @@ def _metadata_to_json(self, metadata: Dict) -> str: Returns: str: JSON representation of metadata """ - return json.dumps(metadata, default=self._decimal_handler) if metadata else "{}" + return json.dumps(metadata, default=self._decimal_handler) if metadata else '{}' def _json_to_metadata(self, json_str: str) -> Dict: """ @@ -418,8 +418,8 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: try: with connection.cursor() as cursor: for item in items: - vector_blob = self._vector_to_blob(item["vector"]) - metadata_json = self._metadata_to_json(item["metadata"]) + vector_blob = self._vector_to_blob(item['vector']) + metadata_json = self._metadata_to_json(item['metadata']) cursor.execute( """ @@ -428,22 +428,20 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: VALUES (:id, :collection_name, :text, :metadata, :vector) """, { - "id": item["id"], - "collection_name": collection_name, - "text": item["text"], - "metadata": metadata_json, - "vector": vector_blob, + 'id': item['id'], + 'collection_name': collection_name, + 'text': item['text'], + 'metadata': metadata_json, + 'vector': vector_blob, }, ) connection.commit() - log.info( - f"Successfully inserted {len(items)} items into collection '{collection_name}'." - ) + log.info(f"Successfully inserted {len(items)} items into collection '{collection_name}'.") except Exception as e: connection.rollback() - log.exception(f"Error during insert: {e}") + log.exception(f'Error during insert: {e}') raise def upsert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -474,8 +472,8 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: try: with connection.cursor() as cursor: for item in items: - vector_blob = self._vector_to_blob(item["vector"]) - metadata_json = self._metadata_to_json(item["metadata"]) + vector_blob = self._vector_to_blob(item['vector']) + metadata_json = self._metadata_to_json(item['metadata']) cursor.execute( """ @@ -493,27 +491,25 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: VALUES (:ins_id, :ins_collection_name, :ins_text, :ins_metadata, :ins_vector) """, { - "merge_id": item["id"], - "upd_collection_name": collection_name, - "upd_text": item["text"], - "upd_metadata": metadata_json, - "upd_vector": vector_blob, - "ins_id": item["id"], - "ins_collection_name": collection_name, - "ins_text": item["text"], - "ins_metadata": metadata_json, - "ins_vector": vector_blob, + 'merge_id': item['id'], + 'upd_collection_name': collection_name, + 'upd_text': item['text'], + 'upd_metadata': metadata_json, + 'upd_vector': vector_blob, + 'ins_id': item['id'], + 'ins_collection_name': collection_name, + 'ins_text': item['text'], + 'ins_metadata': metadata_json, + 'ins_vector': vector_blob, }, ) connection.commit() - log.info( - f"Successfully upserted {len(items)} items into collection '{collection_name}'." - ) + log.info(f"Successfully upserted {len(items)} items into collection '{collection_name}'.") except Exception as e: connection.rollback() - log.exception(f"Error during upsert: {e}") + log.exception(f'Error during upsert: {e}') raise def search( @@ -545,13 +541,11 @@ def search( ... for i, (id, dist) in enumerate(zip(results.ids[0], results.distances[0])): ... log.info(f"Match {i+1}: id={id}, distance={dist}") """ - log.info( - f"Searching items from collection '{collection_name}' with limit {limit}." - ) + log.info(f"Searching items from collection '{collection_name}' with limit {limit}.") try: if not vectors: - log.warning("No vectors provided for search.") + log.warning('No vectors provided for search.') return None num_queries = len(vectors) @@ -577,9 +571,9 @@ def search( FETCH APPROX FIRST :limit ROWS ONLY """, { - "query_vector": vector_blob, - "collection_name": collection_name, - "limit": limit, + 'query_vector': vector_blob, + 'collection_name': collection_name, + 'limit': limit, }, ) @@ -587,35 +581,21 @@ def search( for row in results: ids[qid].append(row[0]) - documents[qid].append( - row[1].read() - if isinstance(row[1], oracledb.LOB) - else str(row[1]) - ) + documents[qid].append(row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1])) # ๐Ÿ”ง FIXED: Parse JSON metadata properly - metadata_str = ( - row[2].read() - if isinstance(row[2], oracledb.LOB) - else row[2] - ) + metadata_str = row[2].read() if isinstance(row[2], oracledb.LOB) else row[2] metadatas[qid].append(self._json_to_metadata(metadata_str)) distances[qid].append(float(row[3])) - log.info( - f"Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results." - ) + log.info(f'Search completed. Found {sum(len(ids[i]) for i in range(num_queries))} total results.') - return SearchResult( - ids=ids, distances=distances, documents=documents, metadatas=metadatas - ) + return SearchResult(ids=ids, distances=distances, documents=documents, metadatas=metadatas) except Exception as e: - log.exception(f"Error during search: {e}") + log.exception(f'Error during search: {e}') return None - def query( - self, collection_name: str, filter: Dict, limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: Dict, limit: Optional[int] = None) -> Optional[GetResult]: """ Query items based on metadata filters. @@ -647,15 +627,15 @@ def query( WHERE collection_name = :collection_name """ - params = {"collection_name": collection_name} + params = {'collection_name': collection_name} for i, (key, value) in enumerate(filter.items()): - param_name = f"value_{i}" + param_name = f'value_{i}' query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}" params[param_name] = str(value) - query += " FETCH FIRST :limit ROWS ONLY" - params["limit"] = limit + query += ' FETCH FIRST :limit ROWS ONLY' + params['limit'] = limit with self.get_connection() as connection: with connection.cursor() as cursor: @@ -663,32 +643,25 @@ def query( results = cursor.fetchall() if not results: - log.info("No results found for query.") + log.info('No results found for query.') return None ids = [[row[0] for row in results]] - documents = [ - [ - row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1]) - for row in results - ] - ] + documents = [[row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1]) for row in results]] # ๐Ÿ”ง FIXED: Parse JSON metadata properly metadatas = [ [ - self._json_to_metadata( - row[2].read() if isinstance(row[2], oracledb.LOB) else row[2] - ) + self._json_to_metadata(row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]) for row in results ] ] - log.info(f"Query completed. Found {len(results)} results.") + log.info(f'Query completed. Found {len(results)} results.') return GetResult(ids=ids, documents=documents, metadatas=metadatas) except Exception as e: - log.exception(f"Error during query: {e}") + log.exception(f'Error during query: {e}') return None def get(self, collection_name: str) -> Optional[GetResult]: @@ -710,9 +683,6 @@ def get(self, collection_name: str) -> Optional[GetResult]: >>> if results: ... print(f"Retrieved {len(results.ids[0])} documents from collection") """ - log.info( - f"Getting items from collection '{collection_name}' with limit {limit}." - ) try: limit = 1000 # Hardcoded limit for get operation @@ -726,28 +696,21 @@ def get(self, collection_name: str) -> Optional[GetResult]: WHERE collection_name = :collection_name FETCH FIRST :limit ROWS ONLY """, - {"collection_name": collection_name, "limit": limit}, + {'collection_name': collection_name, 'limit': limit}, ) results = cursor.fetchall() if not results: - log.info("No results found.") + log.info('No results found.') return None ids = [[row[0] for row in results]] - documents = [ - [ - row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1]) - for row in results - ] - ] + documents = [[row[1].read() if isinstance(row[1], oracledb.LOB) else str(row[1]) for row in results]] # ๐Ÿ”ง FIXED: Parse JSON metadata properly metadatas = [ [ - self._json_to_metadata( - row[2].read() if isinstance(row[2], oracledb.LOB) else row[2] - ) + self._json_to_metadata(row[2].read() if isinstance(row[2], oracledb.LOB) else row[2]) for row in results ] ] @@ -755,7 +718,7 @@ def get(self, collection_name: str) -> Optional[GetResult]: return GetResult(ids=ids, documents=documents, metadatas=metadatas) except Exception as e: - log.exception(f"Error during get: {e}") + log.exception(f'Error during get: {e}') return None def delete( @@ -787,21 +750,19 @@ def delete( log.info(f"Deleting items from collection '{collection_name}'.") try: - query = ( - "DELETE FROM document_chunk WHERE collection_name = :collection_name" - ) - params = {"collection_name": collection_name} + query = 'DELETE FROM document_chunk WHERE collection_name = :collection_name' + params = {'collection_name': collection_name} if ids: # ๐Ÿ”ง FIXED: Use proper parameterized query to prevent SQL injection - placeholders = ",".join([f":id_{i}" for i in range(len(ids))]) - query += f" AND id IN ({placeholders})" + placeholders = ','.join([f':id_{i}' for i in range(len(ids))]) + query += f' AND id IN ({placeholders})' for i, id_val in enumerate(ids): - params[f"id_{i}"] = id_val + params[f'id_{i}'] = id_val if filter: for i, (key, value) in enumerate(filter.items()): - param_name = f"value_{i}" + param_name = f'value_{i}' query += f" AND JSON_VALUE(vmetadata, '$.{key}' RETURNING VARCHAR2(4096)) = :{param_name}" params[param_name] = str(value) @@ -814,7 +775,7 @@ def delete( log.info(f"Deleted {deleted} items from collection '{collection_name}'.") except Exception as e: - log.exception(f"Error during delete: {e}") + log.exception(f'Error during delete: {e}') raise def reset(self) -> None: @@ -830,21 +791,19 @@ def reset(self) -> None: >>> client = Oracle23aiClient() >>> client.reset() # Warning: Removes all data! """ - log.info("Resetting database - deleting all items.") + log.info('Resetting database - deleting all items.') try: with self.get_connection() as connection: with connection.cursor() as cursor: - cursor.execute("DELETE FROM document_chunk") + cursor.execute('DELETE FROM document_chunk') deleted = cursor.rowcount connection.commit() - log.info( - f"Reset complete. Deleted {deleted} items from 'document_chunk' table." - ) + log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.") except Exception as e: - log.exception(f"Error during reset: {e}") + log.exception(f'Error during reset: {e}') raise def close(self) -> None: @@ -859,11 +818,11 @@ def close(self) -> None: >>> client.close() """ try: - if hasattr(self, "pool") and self.pool: + if hasattr(self, 'pool') and self.pool: self.pool.close() - log.info("Oracle Vector Search connection pool closed.") + log.info('Oracle Vector Search connection pool closed.') except Exception as e: - log.exception(f"Error closing connection pool: {e}") + log.exception(f'Error closing connection pool: {e}') def has_collection(self, collection_name: str) -> bool: """ @@ -892,7 +851,7 @@ def has_collection(self, collection_name: str) -> bool: WHERE collection_name = :collection_name FETCH FIRST 1 ROWS ONLY """, - {"collection_name": collection_name}, + {'collection_name': collection_name}, ) count = cursor.fetchone()[0] @@ -900,7 +859,7 @@ def has_collection(self, collection_name: str) -> bool: return count > 0 except Exception as e: - log.exception(f"Error checking collection existence: {e}") + log.exception(f'Error checking collection existence: {e}') return False def delete_collection(self, collection_name: str) -> None: @@ -926,15 +885,13 @@ def delete_collection(self, collection_name: str) -> None: DELETE FROM document_chunk WHERE collection_name = :collection_name """, - {"collection_name": collection_name}, + {'collection_name': collection_name}, ) deleted = cursor.rowcount connection.commit() - log.info( - f"Collection '{collection_name}' deleted. Removed {deleted} items." - ) + log.info(f"Collection '{collection_name}' deleted. Removed {deleted} items.") except Exception as e: log.exception(f"Error deleting collection '{collection_name}': {e}") diff --git a/backend/open_webui/retrieval/vector/dbs/pgvector.py b/backend/open_webui/retrieval/vector/dbs/pgvector.py index 481f9d92fc..4775ff21f4 100644 --- a/backend/open_webui/retrieval/vector/dbs/pgvector.py +++ b/backend/open_webui/retrieval/vector/dbs/pgvector.py @@ -55,7 +55,7 @@ USE_HALFVEC = PGVECTOR_USE_HALFVEC VECTOR_TYPE_FACTORY = HALFVEC if USE_HALFVEC else Vector -VECTOR_OPCLASS = "halfvec_cosine_ops" if USE_HALFVEC else "vector_cosine_ops" +VECTOR_OPCLASS = 'halfvec_cosine_ops' if USE_HALFVEC else 'vector_cosine_ops' Base = declarative_base() log = logging.getLogger(__name__) @@ -65,12 +65,12 @@ def pgcrypto_encrypt(val, key): return func.pgp_sym_encrypt(val, literal(key)) -def pgcrypto_decrypt(col, key, outtype="text"): +def pgcrypto_decrypt(col, key, outtype='text'): return func.cast(func.pgp_sym_decrypt(col, literal(key)), outtype) class DocumentChunk(Base): - __tablename__ = "document_chunk" + __tablename__ = 'document_chunk' id = Column(Text, primary_key=True) vector = Column(VECTOR_TYPE_FACTORY(dim=VECTOR_LENGTH), nullable=True) @@ -86,7 +86,6 @@ class DocumentChunk(Base): class PgvectorClient(VectorDBBase): def __init__(self) -> None: - # if no pgvector uri, use the existing database connection if not PGVECTOR_DB_URL: from open_webui.internal.db import ScopedSession @@ -105,46 +104,44 @@ def __init__(self) -> None: poolclass=QueuePool, ) else: - engine = create_engine( - PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool - ) + engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool) else: engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True) - SessionLocal = sessionmaker( - autocommit=False, autoflush=False, bind=engine, expire_on_commit=False - ) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) self.session = scoped_session(SessionLocal) try: # Ensure the pgvector extension is available # Use a conditional check to avoid permission issues on Azure PostgreSQL if PGVECTOR_CREATE_EXTENSION: - self.session.execute(text(""" + self.session.execute( + text(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'vector') THEN CREATE EXTENSION IF NOT EXISTS vector; END IF; END $$; - """)) + """) + ) if PGVECTOR_PGCRYPTO: # Ensure the pgcrypto extension is available for encryption # Use a conditional check to avoid permission issues on Azure PostgreSQL - self.session.execute(text(""" + self.session.execute( + text(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') THEN CREATE EXTENSION IF NOT EXISTS pgcrypto; END IF; END $$; - """)) + """) + ) if not PGVECTOR_PGCRYPTO_KEY: - raise ValueError( - "PGVECTOR_PGCRYPTO_KEY must be set when PGVECTOR_PGCRYPTO is enabled." - ) + raise ValueError('PGVECTOR_PGCRYPTO_KEY must be set when PGVECTOR_PGCRYPTO is enabled.') # Check vector length consistency self.check_vector_length() @@ -160,15 +157,14 @@ def __init__(self) -> None: self.session.execute( text( - "CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name " - "ON document_chunk (collection_name);" + 'CREATE INDEX IF NOT EXISTS idx_document_chunk_collection_name ON document_chunk (collection_name);' ) ) self.session.commit() - log.info("Initialization complete.") + log.info('Initialization complete.') except Exception as e: self.session.rollback() - log.exception(f"Error during initialization: {e}") + log.exception(f'Error during initialization: {e}') raise @staticmethod @@ -176,7 +172,7 @@ def _extract_index_method(index_def: Optional[str]) -> Optional[str]: if not index_def: return None try: - after_using = index_def.lower().split("using ", 1)[1] + after_using = index_def.lower().split('using ', 1)[1] return after_using.split()[0] except (IndexError, AttributeError): return None @@ -189,23 +185,23 @@ def _vector_index_configuration(self) -> Tuple[str, str]: index_method, ) elif USE_HALFVEC: - index_method = "hnsw" + index_method = 'hnsw' log.info( - "VECTOR_LENGTH=%s exceeds 2000; using halfvec column type with hnsw index.", + 'VECTOR_LENGTH=%s exceeds 2000; using halfvec column type with hnsw index.', VECTOR_LENGTH, ) else: - index_method = "ivfflat" + index_method = 'ivfflat' - if index_method == "hnsw": - index_options = f"WITH (m = {PGVECTOR_HNSW_M}, ef_construction = {PGVECTOR_HNSW_EF_CONSTRUCTION})" + if index_method == 'hnsw': + index_options = f'WITH (m = {PGVECTOR_HNSW_M}, ef_construction = {PGVECTOR_HNSW_EF_CONSTRUCTION})' else: - index_options = f"WITH (lists = {PGVECTOR_IVFFLAT_LISTS})" + index_options = f'WITH (lists = {PGVECTOR_IVFFLAT_LISTS})' return index_method, index_options def _ensure_vector_index(self, index_method: str, index_options: str) -> None: - index_name = "idx_document_chunk_vector" + index_name = 'idx_document_chunk_vector' existing_index_def = self.session.execute( text(""" SELECT indexdef @@ -214,7 +210,7 @@ def _ensure_vector_index(self, index_method: str, index_options: str) -> None: AND tablename = 'document_chunk' AND indexname = :index_name """), - {"index_name": index_name}, + {'index_name': index_name}, ).scalar() existing_method = self._extract_index_method(existing_index_def) @@ -222,23 +218,23 @@ def _ensure_vector_index(self, index_method: str, index_options: str) -> None: raise RuntimeError( f"Existing pgvector index '{index_name}' uses method '{existing_method}' but configuration now " f"requires '{index_method}'. Automatic rebuild is disabled to prevent long-running maintenance. " - "Drop the index manually (optionally after tuning maintenance_work_mem/max_parallel_maintenance_workers) " - "and recreate it with the new method before restarting Open WebUI." + 'Drop the index manually (optionally after tuning maintenance_work_mem/max_parallel_maintenance_workers) ' + 'and recreate it with the new method before restarting Open WebUI.' ) if not existing_index_def: index_sql = ( - f"CREATE INDEX IF NOT EXISTS {index_name} " - f"ON document_chunk USING {index_method} (vector {VECTOR_OPCLASS})" + f'CREATE INDEX IF NOT EXISTS {index_name} ' + f'ON document_chunk USING {index_method} (vector {VECTOR_OPCLASS})' ) if index_options: - index_sql = f"{index_sql} {index_options}" + index_sql = f'{index_sql} {index_options}' self.session.execute(text(index_sql)) log.info( "Ensured vector index '%s' using %s%s.", index_name, index_method, - f" {index_options}" if index_options else "", + f' {index_options}' if index_options else '', ) def check_vector_length(self) -> None: @@ -249,16 +245,14 @@ def check_vector_length(self) -> None: metadata = MetaData() try: # Attempt to reflect the 'document_chunk' table - document_chunk_table = Table( - "document_chunk", metadata, autoload_with=self.session.bind - ) + document_chunk_table = Table('document_chunk', metadata, autoload_with=self.session.bind) except NoSuchTableError: # Table does not exist; no action needed return # Proceed to check the vector column - if "vector" in document_chunk_table.columns: - vector_column = document_chunk_table.columns["vector"] + if 'vector' in document_chunk_table.columns: + vector_column = document_chunk_table.columns['vector'] vector_type = vector_column.type expected_type = HALFVEC if USE_HALFVEC else Vector @@ -268,16 +262,14 @@ def check_vector_length(self) -> None: f"('{expected_type.__name__}') for VECTOR_LENGTH {VECTOR_LENGTH}." ) - db_vector_length = getattr(vector_type, "dim", None) + db_vector_length = getattr(vector_type, 'dim', None) if db_vector_length is not None and db_vector_length != VECTOR_LENGTH: raise Exception( - f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. " - "Cannot change vector size after initialization without migrating the data." + f'VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. ' + 'Cannot change vector size after initialization without migrating the data.' ) else: - raise Exception( - "The 'vector' column does not exist in the 'document_chunk' table." - ) + raise Exception("The 'vector' column does not exist in the 'document_chunk' table.") def adjust_vector_length(self, vector: List[float]) -> List[float]: # Adjust vector to have length VECTOR_LENGTH @@ -294,10 +286,10 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: try: if PGVECTOR_PGCRYPTO: for item in items: - vector = self.adjust_vector_length(item["vector"]) + vector = self.adjust_vector_length(item['vector']) # Use raw SQL for BYTEA/pgcrypto # Ensure metadata is converted to its JSON text representation - json_metadata = json.dumps(item["metadata"]) + json_metadata = json.dumps(item['metadata']) self.session.execute( text(""" INSERT INTO document_chunk @@ -310,12 +302,12 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: ON CONFLICT (id) DO NOTHING """), { - "id": item["id"], - "vector": vector, - "collection_name": collection_name, - "text": item["text"], - "metadata_text": json_metadata, - "key": PGVECTOR_PGCRYPTO_KEY, + 'id': item['id'], + 'vector': vector, + 'collection_name': collection_name, + 'text': item['text'], + 'metadata_text': json_metadata, + 'key': PGVECTOR_PGCRYPTO_KEY, }, ) self.session.commit() @@ -324,31 +316,29 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: else: new_items = [] for item in items: - vector = self.adjust_vector_length(item["vector"]) + vector = self.adjust_vector_length(item['vector']) new_chunk = DocumentChunk( - id=item["id"], + id=item['id'], vector=vector, collection_name=collection_name, - text=item["text"], - vmetadata=process_metadata(item["metadata"]), + text=item['text'], + vmetadata=process_metadata(item['metadata']), ) new_items.append(new_chunk) self.session.bulk_save_objects(new_items) self.session.commit() - log.info( - f"Inserted {len(new_items)} items into collection '{collection_name}'." - ) + log.info(f"Inserted {len(new_items)} items into collection '{collection_name}'.") except Exception as e: self.session.rollback() - log.exception(f"Error during insert: {e}") + log.exception(f'Error during insert: {e}') raise def upsert(self, collection_name: str, items: List[VectorItem]) -> None: try: if PGVECTOR_PGCRYPTO: for item in items: - vector = self.adjust_vector_length(item["vector"]) - json_metadata = json.dumps(item["metadata"]) + vector = self.adjust_vector_length(item['vector']) + json_metadata = json.dumps(item['metadata']) self.session.execute( text(""" INSERT INTO document_chunk @@ -365,47 +355,39 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: vmetadata = EXCLUDED.vmetadata """), { - "id": item["id"], - "vector": vector, - "collection_name": collection_name, - "text": item["text"], - "metadata_text": json_metadata, - "key": PGVECTOR_PGCRYPTO_KEY, + 'id': item['id'], + 'vector': vector, + 'collection_name': collection_name, + 'text': item['text'], + 'metadata_text': json_metadata, + 'key': PGVECTOR_PGCRYPTO_KEY, }, ) self.session.commit() log.info(f"Encrypted & upserted {len(items)} into '{collection_name}'") else: for item in items: - vector = self.adjust_vector_length(item["vector"]) - existing = ( - self.session.query(DocumentChunk) - .filter(DocumentChunk.id == item["id"]) - .first() - ) + vector = self.adjust_vector_length(item['vector']) + existing = self.session.query(DocumentChunk).filter(DocumentChunk.id == item['id']).first() if existing: existing.vector = vector - existing.text = item["text"] - existing.vmetadata = process_metadata(item["metadata"]) - existing.collection_name = ( - collection_name # Update collection_name if necessary - ) + existing.text = item['text'] + existing.vmetadata = process_metadata(item['metadata']) + existing.collection_name = collection_name # Update collection_name if necessary else: new_chunk = DocumentChunk( - id=item["id"], + id=item['id'], vector=vector, collection_name=collection_name, - text=item["text"], - vmetadata=process_metadata(item["metadata"]), + text=item['text'], + vmetadata=process_metadata(item['metadata']), ) self.session.add(new_chunk) self.session.commit() - log.info( - f"Upserted {len(items)} items into collection '{collection_name}'." - ) + log.info(f"Upserted {len(items)} items into collection '{collection_name}'.") except Exception as e: self.session.rollback() - log.exception(f"Error during upsert: {e}") + log.exception(f'Error during upsert: {e}') raise def search( @@ -427,38 +409,26 @@ def vector_expr(vector): return cast(array(vector), VECTOR_TYPE_FACTORY(VECTOR_LENGTH)) # Create the values for query vectors - qid_col = column("qid", Integer) - q_vector_col = column("q_vector", VECTOR_TYPE_FACTORY(VECTOR_LENGTH)) + qid_col = column('qid', Integer) + q_vector_col = column('q_vector', VECTOR_TYPE_FACTORY(VECTOR_LENGTH)) query_vectors = ( values(qid_col, q_vector_col) - .data( - [(idx, vector_expr(vector)) for idx, vector in enumerate(vectors)] - ) - .alias("query_vectors") + .data([(idx, vector_expr(vector)) for idx, vector in enumerate(vectors)]) + .alias('query_vectors') ) result_fields = [ DocumentChunk.id, ] if PGVECTOR_PGCRYPTO: + result_fields.append(pgcrypto_decrypt(DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text).label('text')) result_fields.append( - pgcrypto_decrypt( - DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text - ).label("text") - ) - result_fields.append( - pgcrypto_decrypt( - DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB - ).label("vmetadata") + pgcrypto_decrypt(DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB).label('vmetadata') ) else: result_fields.append(DocumentChunk.text) result_fields.append(DocumentChunk.vmetadata) - result_fields.append( - (DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label( - "distance" - ) - ) + result_fields.append((DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)).label('distance')) # Build the lateral subquery for each query vector where_clauses = [DocumentChunk.collection_name == collection_name] @@ -466,9 +436,9 @@ def vector_expr(vector): # Apply metadata filter if provided if filter: for key, value in filter.items(): - if isinstance(value, dict) and "$in" in value: + if isinstance(value, dict) and '$in' in value: # Handle $in operator: {"field": {"$in": [values]}} - in_values = value["$in"] + in_values = value['$in'] if PGVECTOR_PGCRYPTO: where_clauses.append( pgcrypto_decrypt( @@ -478,11 +448,7 @@ def vector_expr(vector): )[key].astext.in_([str(v) for v in in_values]) ) else: - where_clauses.append( - DocumentChunk.vmetadata[key].astext.in_( - [str(v) for v in in_values] - ) - ) + where_clauses.append(DocumentChunk.vmetadata[key].astext.in_([str(v) for v in in_values])) else: # Handle simple equality: {"field": "value"} if PGVECTOR_PGCRYPTO: @@ -495,20 +461,16 @@ def vector_expr(vector): == str(value) ) else: - where_clauses.append( - DocumentChunk.vmetadata[key].astext == str(value) - ) + where_clauses.append(DocumentChunk.vmetadata[key].astext == str(value)) subq = ( select(*result_fields) .where(*where_clauses) - .order_by( - (DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector)) - ) + .order_by((DocumentChunk.vector.cosine_distance(query_vectors.c.q_vector))) ) if limit is not None: subq = subq.limit(limit) - subq = subq.lateral("result") + subq = subq.lateral('result') # Build the main query by joining query_vectors and the lateral subquery stmt = ( @@ -550,17 +512,13 @@ def vector_expr(vector): metadatas[qid].append(row.vmetadata) self.session.rollback() # read-only transaction - return SearchResult( - ids=ids, distances=distances, documents=documents, metadatas=metadatas - ) + return SearchResult(ids=ids, distances=distances, documents=documents, metadatas=metadatas) except Exception as e: self.session.rollback() - log.exception(f"Error during search: {e}") + log.exception(f'Error during search: {e}') return None - def query( - self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None) -> Optional[GetResult]: try: if PGVECTOR_PGCRYPTO: # Build where clause for vmetadata filter @@ -568,32 +526,22 @@ def query( for key, value in filter.items(): # decrypt then check key: JSON filter after decryption where_clauses.append( - pgcrypto_decrypt( - DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB - )[key].astext + pgcrypto_decrypt(DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB)[key].astext == str(value) ) stmt = select( DocumentChunk.id, - pgcrypto_decrypt( - DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text - ).label("text"), - pgcrypto_decrypt( - DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB - ).label("vmetadata"), + pgcrypto_decrypt(DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text).label('text'), + pgcrypto_decrypt(DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB).label('vmetadata'), ).where(*where_clauses) if limit is not None: stmt = stmt.limit(limit) results = self.session.execute(stmt).all() else: - query = self.session.query(DocumentChunk).filter( - DocumentChunk.collection_name == collection_name - ) + query = self.session.query(DocumentChunk).filter(DocumentChunk.collection_name == collection_name) for key, value in filter.items(): - query = query.filter( - DocumentChunk.vmetadata[key].astext == str(value) - ) + query = query.filter(DocumentChunk.vmetadata[key].astext == str(value)) if limit is not None: query = query.limit(limit) @@ -615,22 +563,16 @@ def query( ) except Exception as e: self.session.rollback() - log.exception(f"Error during query: {e}") + log.exception(f'Error during query: {e}') return None - def get( - self, collection_name: str, limit: Optional[int] = None - ) -> Optional[GetResult]: + def get(self, collection_name: str, limit: Optional[int] = None) -> Optional[GetResult]: try: if PGVECTOR_PGCRYPTO: stmt = select( DocumentChunk.id, - pgcrypto_decrypt( - DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text - ).label("text"), - pgcrypto_decrypt( - DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB - ).label("vmetadata"), + pgcrypto_decrypt(DocumentChunk.text, PGVECTOR_PGCRYPTO_KEY, Text).label('text'), + pgcrypto_decrypt(DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB).label('vmetadata'), ).where(DocumentChunk.collection_name == collection_name) if limit is not None: stmt = stmt.limit(limit) @@ -639,10 +581,7 @@ def get( documents = [[row.text for row in results]] metadatas = [[row.vmetadata for row in results]] else: - - query = self.session.query(DocumentChunk).filter( - DocumentChunk.collection_name == collection_name - ) + query = self.session.query(DocumentChunk).filter(DocumentChunk.collection_name == collection_name) if limit is not None: query = query.limit(limit) @@ -659,7 +598,7 @@ def get( return GetResult(ids=ids, documents=documents, metadatas=metadatas) except Exception as e: self.session.rollback() - log.exception(f"Error during get: {e}") + log.exception(f'Error during get: {e}') return None def delete( @@ -676,43 +615,35 @@ def delete( if filter: for key, value in filter.items(): wheres.append( - pgcrypto_decrypt( - DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB - )[key].astext + pgcrypto_decrypt(DocumentChunk.vmetadata, PGVECTOR_PGCRYPTO_KEY, JSONB)[key].astext == str(value) ) stmt = DocumentChunk.__table__.delete().where(*wheres) result = self.session.execute(stmt) deleted = result.rowcount else: - query = self.session.query(DocumentChunk).filter( - DocumentChunk.collection_name == collection_name - ) + query = self.session.query(DocumentChunk).filter(DocumentChunk.collection_name == collection_name) if ids: query = query.filter(DocumentChunk.id.in_(ids)) if filter: for key, value in filter.items(): - query = query.filter( - DocumentChunk.vmetadata[key].astext == str(value) - ) + query = query.filter(DocumentChunk.vmetadata[key].astext == str(value)) deleted = query.delete(synchronize_session=False) self.session.commit() log.info(f"Deleted {deleted} items from collection '{collection_name}'.") except Exception as e: self.session.rollback() - log.exception(f"Error during delete: {e}") + log.exception(f'Error during delete: {e}') raise def reset(self) -> None: try: deleted = self.session.query(DocumentChunk).delete() self.session.commit() - log.info( - f"Reset complete. Deleted {deleted} items from 'document_chunk' table." - ) + log.info(f"Reset complete. Deleted {deleted} items from 'document_chunk' table.") except Exception as e: self.session.rollback() - log.exception(f"Error during reset: {e}") + log.exception(f'Error during reset: {e}') raise def close(self) -> None: @@ -721,16 +652,14 @@ def close(self) -> None: def has_collection(self, collection_name: str) -> bool: try: exists = ( - self.session.query(DocumentChunk) - .filter(DocumentChunk.collection_name == collection_name) - .first() + self.session.query(DocumentChunk).filter(DocumentChunk.collection_name == collection_name).first() is not None ) self.session.rollback() # read-only transaction return exists except Exception as e: self.session.rollback() - log.exception(f"Error checking collection existence: {e}") + log.exception(f'Error checking collection existence: {e}') return False def delete_collection(self, collection_name: str) -> None: diff --git a/backend/open_webui/retrieval/vector/dbs/pinecone.py b/backend/open_webui/retrieval/vector/dbs/pinecone.py index 27bc50b70e..6469ac9172 100644 --- a/backend/open_webui/retrieval/vector/dbs/pinecone.py +++ b/backend/open_webui/retrieval/vector/dbs/pinecone.py @@ -45,7 +45,7 @@ class PineconeClient(VectorDBBase): def __init__(self): - self.collection_prefix = "open-webui" + self.collection_prefix = 'open-webui' # Validate required configuration self._validate_config() @@ -67,7 +67,7 @@ def __init__(self): timeout=30, # Reasonable timeout for operations ) self.using_grpc = True - log.info("Using Pinecone gRPC client for optimal performance") + log.info('Using Pinecone gRPC client for optimal performance') else: # Fallback to HTTP client with enhanced connection pooling self.client = Pinecone( @@ -76,7 +76,7 @@ def __init__(self): timeout=30, # Reasonable timeout for operations ) self.using_grpc = False - log.info("Using Pinecone HTTP client (gRPC not available)") + log.info('Using Pinecone HTTP client (gRPC not available)') # Persistent executor for batch operations self._executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) @@ -88,20 +88,18 @@ def _validate_config(self) -> None: """Validate that all required configuration variables are set.""" missing_vars = [] if not PINECONE_API_KEY: - missing_vars.append("PINECONE_API_KEY") + missing_vars.append('PINECONE_API_KEY') if not PINECONE_ENVIRONMENT: - missing_vars.append("PINECONE_ENVIRONMENT") + missing_vars.append('PINECONE_ENVIRONMENT') if not PINECONE_INDEX_NAME: - missing_vars.append("PINECONE_INDEX_NAME") + missing_vars.append('PINECONE_INDEX_NAME') if not PINECONE_DIMENSION: - missing_vars.append("PINECONE_DIMENSION") + missing_vars.append('PINECONE_DIMENSION') if not PINECONE_CLOUD: - missing_vars.append("PINECONE_CLOUD") + missing_vars.append('PINECONE_CLOUD') if missing_vars: - raise ValueError( - f"Required configuration missing: {', '.join(missing_vars)}" - ) + raise ValueError(f'Required configuration missing: {", ".join(missing_vars)}') def _initialize_index(self) -> None: """Initialize the Pinecone index.""" @@ -126,8 +124,8 @@ def _initialize_index(self) -> None: ) except Exception as e: - log.error(f"Failed to initialize Pinecone index: {e}") - raise RuntimeError(f"Failed to initialize Pinecone index: {e}") + log.error(f'Failed to initialize Pinecone index: {e}') + raise RuntimeError(f'Failed to initialize Pinecone index: {e}') def _retry_pinecone_operation(self, operation_func, max_retries=3): """Retry Pinecone operations with exponential backoff for rate limits and network issues.""" @@ -140,18 +138,18 @@ def _retry_pinecone_operation(self, operation_func, max_retries=3): is_retryable = any( keyword in error_str for keyword in [ - "rate limit", - "quota", - "timeout", - "network", - "connection", - "unavailable", - "internal error", - "429", - "500", - "502", - "503", - "504", + 'rate limit', + 'quota', + 'timeout', + 'network', + 'connection', + 'unavailable', + 'internal error', + '429', + '500', + '502', + '503', + '504', ] ) @@ -162,45 +160,42 @@ def _retry_pinecone_operation(self, operation_func, max_retries=3): # Exponential backoff with jitter delay = (2**attempt) + random.uniform(0, 1) log.warning( - f"Pinecone operation failed (attempt {attempt + 1}/{max_retries}), " - f"retrying in {delay:.2f}s: {e}" + f'Pinecone operation failed (attempt {attempt + 1}/{max_retries}), retrying in {delay:.2f}s: {e}' ) time.sleep(delay) - def _create_points( - self, items: List[VectorItem], collection_name_with_prefix: str - ) -> List[Dict[str, Any]]: + def _create_points(self, items: List[VectorItem], collection_name_with_prefix: str) -> List[Dict[str, Any]]: """Convert VectorItem objects to Pinecone point format.""" points = [] for item in items: # Start with any existing metadata or an empty dict - metadata = item.get("metadata", {}).copy() if item.get("metadata") else {} + metadata = item.get('metadata', {}).copy() if item.get('metadata') else {} # Add text to metadata if available - if "text" in item: - metadata["text"] = item["text"] + if 'text' in item: + metadata['text'] = item['text'] # Always add collection_name to metadata for filtering - metadata["collection_name"] = collection_name_with_prefix + metadata['collection_name'] = collection_name_with_prefix point = { - "id": item["id"], - "values": item["vector"], - "metadata": process_metadata(metadata), + 'id': item['id'], + 'values': item['vector'], + 'metadata': process_metadata(metadata), } points.append(point) return points def _get_collection_name_with_prefix(self, collection_name: str) -> str: """Get the collection name with prefix.""" - return f"{self.collection_prefix}_{collection_name}" + return f'{self.collection_prefix}_{collection_name}' def _normalize_distance(self, score: float) -> float: """Normalize distance score based on the metric used.""" - if self.metric.lower() == "cosine": + if self.metric.lower() == 'cosine': # Cosine similarity ranges from -1 to 1, normalize to 0 to 1 return (score + 1.0) / 2.0 - elif self.metric.lower() in ["euclidean", "dotproduct"]: + elif self.metric.lower() in ['euclidean', 'dotproduct']: # These are already suitable for ranking (smaller is better for Euclidean) return score else: @@ -214,68 +209,56 @@ def _result_to_get_result(self, matches: list) -> GetResult: metadatas = [] for match in matches: - metadata = getattr(match, "metadata", {}) or {} - ids.append(match.id if hasattr(match, "id") else match["id"]) - documents.append(metadata.get("text", "")) + metadata = getattr(match, 'metadata', {}) or {} + ids.append(match.id if hasattr(match, 'id') else match['id']) + documents.append(metadata.get('text', '')) metadatas.append(metadata) return GetResult( **{ - "ids": [ids], - "documents": [documents], - "metadatas": [metadatas], + 'ids': [ids], + 'documents': [documents], + 'metadatas': [metadatas], } ) def has_collection(self, collection_name: str) -> bool: """Check if a collection exists by searching for at least one item.""" - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) try: # Search for at least 1 item with this collection name in metadata response = self.index.query( vector=[0.0] * self.dimension, # dummy vector top_k=1, - filter={"collection_name": collection_name_with_prefix}, + filter={'collection_name': collection_name_with_prefix}, include_metadata=False, ) - matches = getattr(response, "matches", []) or [] + matches = getattr(response, 'matches', []) or [] return len(matches) > 0 except Exception as e: - log.exception( - f"Error checking collection '{collection_name_with_prefix}': {e}" - ) + log.exception(f"Error checking collection '{collection_name_with_prefix}': {e}") return False def delete_collection(self, collection_name: str) -> None: """Delete a collection by removing all vectors with the collection name in metadata.""" - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) try: - self.index.delete(filter={"collection_name": collection_name_with_prefix}) - log.info( - f"Collection '{collection_name_with_prefix}' deleted (all vectors removed)." - ) + self.index.delete(filter={'collection_name': collection_name_with_prefix}) + log.info(f"Collection '{collection_name_with_prefix}' deleted (all vectors removed).") except Exception as e: - log.warning( - f"Failed to delete collection '{collection_name_with_prefix}': {e}" - ) + log.warning(f"Failed to delete collection '{collection_name_with_prefix}': {e}") raise def insert(self, collection_name: str, items: List[VectorItem]) -> None: """Insert vectors into a collection.""" if not items: - log.warning("No items to insert") + log.warning('No items to insert') return start_time = time.time() - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) points = self._create_points(items, collection_name_with_prefix) # Parallelize batch inserts for performance @@ -288,26 +271,23 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: try: future.result() except Exception as e: - log.error(f"Error inserting batch: {e}") + log.error(f'Error inserting batch: {e}') raise elapsed = time.time() - start_time - log.debug(f"Insert of {len(points)} vectors took {elapsed:.2f} seconds") + log.debug(f'Insert of {len(points)} vectors took {elapsed:.2f} seconds') log.info( - f"Successfully inserted {len(points)} vectors in parallel batches " - f"into '{collection_name_with_prefix}'" + f"Successfully inserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'" ) def upsert(self, collection_name: str, items: List[VectorItem]) -> None: """Upsert (insert or update) vectors into a collection.""" if not items: - log.warning("No items to upsert") + log.warning('No items to upsert') return start_time = time.time() - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) points = self._create_points(items, collection_name_with_prefix) # Parallelize batch upserts for performance @@ -320,78 +300,53 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: try: future.result() except Exception as e: - log.error(f"Error upserting batch: {e}") + log.error(f'Error upserting batch: {e}') raise elapsed = time.time() - start_time - log.debug(f"Upsert of {len(points)} vectors took {elapsed:.2f} seconds") + log.debug(f'Upsert of {len(points)} vectors took {elapsed:.2f} seconds') log.info( - f"Successfully upserted {len(points)} vectors in parallel batches " - f"into '{collection_name_with_prefix}'" + f"Successfully upserted {len(points)} vectors in parallel batches into '{collection_name_with_prefix}'" ) async def insert_async(self, collection_name: str, items: List[VectorItem]) -> None: """Async version of insert using asyncio and run_in_executor for improved performance.""" if not items: - log.warning("No items to insert") + log.warning('No items to insert') return - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) points = self._create_points(items, collection_name_with_prefix) # Create batches - batches = [ - points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE) - ] + batches = [points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE)] loop = asyncio.get_event_loop() - tasks = [ - loop.run_in_executor( - None, functools.partial(self.index.upsert, vectors=batch) - ) - for batch in batches - ] + tasks = [loop.run_in_executor(None, functools.partial(self.index.upsert, vectors=batch)) for batch in batches] results = await asyncio.gather(*tasks, return_exceptions=True) for result in results: if isinstance(result, Exception): - log.error(f"Error in async insert batch: {result}") + log.error(f'Error in async insert batch: {result}') raise result - log.info( - f"Successfully async inserted {len(points)} vectors in batches " - f"into '{collection_name_with_prefix}'" - ) + log.info(f"Successfully async inserted {len(points)} vectors in batches into '{collection_name_with_prefix}'") async def upsert_async(self, collection_name: str, items: List[VectorItem]) -> None: """Async version of upsert using asyncio and run_in_executor for improved performance.""" if not items: - log.warning("No items to upsert") + log.warning('No items to upsert') return - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) points = self._create_points(items, collection_name_with_prefix) # Create batches - batches = [ - points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE) - ] + batches = [points[i : i + BATCH_SIZE] for i in range(0, len(points), BATCH_SIZE)] loop = asyncio.get_event_loop() - tasks = [ - loop.run_in_executor( - None, functools.partial(self.index.upsert, vectors=batch) - ) - for batch in batches - ] + tasks = [loop.run_in_executor(None, functools.partial(self.index.upsert, vectors=batch)) for batch in batches] results = await asyncio.gather(*tasks, return_exceptions=True) for result in results: if isinstance(result, Exception): - log.error(f"Error in async upsert batch: {result}") + log.error(f'Error in async upsert batch: {result}') raise result - log.info( - f"Successfully async upserted {len(points)} vectors in batches " - f"into '{collection_name_with_prefix}'" - ) + log.info(f"Successfully async upserted {len(points)} vectors in batches into '{collection_name_with_prefix}'") def search( self, @@ -402,12 +357,10 @@ def search( ) -> Optional[SearchResult]: """Search for similar vectors in a collection.""" if not vectors or not vectors[0]: - log.warning("No vectors provided for search") + log.warning('No vectors provided for search') return None - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) if limit is None or limit <= 0: limit = NO_LIMIT @@ -421,10 +374,10 @@ def search( vector=query_vector, top_k=limit, include_metadata=True, - filter={"collection_name": collection_name_with_prefix}, + filter={'collection_name': collection_name_with_prefix}, ) - matches = getattr(query_response, "matches", []) or [] + matches = getattr(query_response, 'matches', []) or [] if not matches: # Return empty result if no matches return SearchResult( @@ -438,12 +391,7 @@ def search( get_result = self._result_to_get_result(matches) # Calculate normalized distances based on metric - distances = [ - [ - self._normalize_distance(getattr(match, "score", 0.0)) - for match in matches - ] - ] + distances = [[self._normalize_distance(getattr(match, 'score', 0.0)) for match in matches]] return SearchResult( ids=get_result.ids, @@ -455,13 +403,9 @@ def search( log.error(f"Error searching in '{collection_name_with_prefix}': {e}") return None - def query( - self, collection_name: str, filter: Dict, limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: Dict, limit: Optional[int] = None) -> Optional[GetResult]: """Query vectors by metadata filter.""" - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) if limit is None or limit <= 0: limit = NO_LIMIT @@ -471,7 +415,7 @@ def query( zero_vector = [0.0] * self.dimension # Combine user filter with collection_name - pinecone_filter = {"collection_name": collection_name_with_prefix} + pinecone_filter = {'collection_name': collection_name_with_prefix} if filter: pinecone_filter.update(filter) @@ -483,7 +427,7 @@ def query( include_metadata=True, ) - matches = getattr(query_response, "matches", []) or [] + matches = getattr(query_response, 'matches', []) or [] return self._result_to_get_result(matches) except Exception as e: @@ -492,9 +436,7 @@ def query( def get(self, collection_name: str) -> Optional[GetResult]: """Get all vectors in a collection.""" - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) try: # Use a zero vector for fetching all entries @@ -505,10 +447,10 @@ def get(self, collection_name: str) -> Optional[GetResult]: vector=zero_vector, top_k=NO_LIMIT, include_metadata=True, - filter={"collection_name": collection_name_with_prefix}, + filter={'collection_name': collection_name_with_prefix}, ) - matches = getattr(query_response, "matches", []) or [] + matches = getattr(query_response, 'matches', []) or [] return self._result_to_get_result(matches) except Exception as e: @@ -522,9 +464,7 @@ def delete( filter: Optional[Dict] = None, ) -> None: """Delete vectors by IDs or filter.""" - collection_name_with_prefix = self._get_collection_name_with_prefix( - collection_name - ) + collection_name_with_prefix = self._get_collection_name_with_prefix(collection_name) try: if ids: @@ -534,28 +474,20 @@ def delete( # Note: When deleting by ID, we can't filter by collection_name # This is a limitation of Pinecone - be careful with ID uniqueness self.index.delete(ids=batch_ids) - log.debug( - f"Deleted batch of {len(batch_ids)} vectors by ID " - f"from '{collection_name_with_prefix}'" - ) - log.info( - f"Successfully deleted {len(ids)} vectors by ID " - f"from '{collection_name_with_prefix}'" - ) + log.debug(f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'") + log.info(f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'") elif filter: # Combine user filter with collection_name - pinecone_filter = {"collection_name": collection_name_with_prefix} + pinecone_filter = {'collection_name': collection_name_with_prefix} if filter: pinecone_filter.update(filter) # Delete by metadata filter self.index.delete(filter=pinecone_filter) - log.info( - f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'" - ) + log.info(f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'") else: - log.warning("No ids or filter provided for delete operation") + log.warning('No ids or filter provided for delete operation') except Exception as e: log.error(f"Error deleting from collection '{collection_name}': {e}") @@ -565,9 +497,9 @@ def reset(self) -> None: """Reset the database by deleting all collections.""" try: self.index.delete(delete_all=True) - log.info("All vectors successfully deleted from the index.") + log.info('All vectors successfully deleted from the index.') except Exception as e: - log.error(f"Failed to reset Pinecone index: {e}") + log.error(f'Failed to reset Pinecone index: {e}') raise def close(self): @@ -576,7 +508,7 @@ def close(self): # The new Pinecone client doesn't need explicit closing pass except Exception as e: - log.warning(f"Failed to clean up Pinecone resources: {e}") + log.warning(f'Failed to clean up Pinecone resources: {e}') self._executor.shutdown(wait=True) def __enter__(self): diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant.py b/backend/open_webui/retrieval/vector/dbs/qdrant.py index e774bb875f..f050bebeb5 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant.py @@ -76,19 +76,19 @@ def _result_to_get_result(self, points) -> GetResult: for point in points: payload = point.payload ids.append(point.id) - documents.append(payload["text"]) - metadatas.append(payload["metadata"]) + documents.append(payload['text']) + metadatas.append(payload['metadata']) return GetResult( **{ - "ids": [ids], - "documents": [documents], - "metadatas": [metadatas], + 'ids': [ids], + 'documents': [documents], + 'metadatas': [metadatas], } ) def _create_collection(self, collection_name: str, dimension: int): - collection_name_with_prefix = f"{self.collection_prefix}_{collection_name}" + collection_name_with_prefix = f'{self.collection_prefix}_{collection_name}' self.client.create_collection( collection_name=collection_name_with_prefix, vectors_config=models.VectorParams( @@ -104,7 +104,7 @@ def _create_collection(self, collection_name: str, dimension: int): # Create payload indexes for efficient filtering self.client.create_payload_index( collection_name=collection_name_with_prefix, - field_name="metadata.hash", + field_name='metadata.hash', field_schema=models.KeywordIndexParams( type=models.KeywordIndexType.KEYWORD, is_tenant=False, @@ -113,40 +113,34 @@ def _create_collection(self, collection_name: str, dimension: int): ) self.client.create_payload_index( collection_name=collection_name_with_prefix, - field_name="metadata.file_id", + field_name='metadata.file_id', field_schema=models.KeywordIndexParams( type=models.KeywordIndexType.KEYWORD, is_tenant=False, on_disk=self.QDRANT_ON_DISK, ), ) - log.info(f"collection {collection_name_with_prefix} successfully created!") + log.info(f'collection {collection_name_with_prefix} successfully created!') def _create_collection_if_not_exists(self, collection_name, dimension): if not self.has_collection(collection_name=collection_name): - self._create_collection( - collection_name=collection_name, dimension=dimension - ) + self._create_collection(collection_name=collection_name, dimension=dimension) def _create_points(self, items: list[VectorItem]): return [ PointStruct( - id=item["id"], - vector=item["vector"], - payload={"text": item["text"], "metadata": item["metadata"]}, + id=item['id'], + vector=item['vector'], + payload={'text': item['text'], 'metadata': item['metadata']}, ) for item in items ] def has_collection(self, collection_name: str) -> bool: - return self.client.collection_exists( - f"{self.collection_prefix}_{collection_name}" - ) + return self.client.collection_exists(f'{self.collection_prefix}_{collection_name}') def delete_collection(self, collection_name: str): - return self.client.delete_collection( - collection_name=f"{self.collection_prefix}_{collection_name}" - ) + return self.client.delete_collection(collection_name=f'{self.collection_prefix}_{collection_name}') def search( self, @@ -160,7 +154,7 @@ def search( limit = NO_LIMIT # otherwise qdrant would set limit to 10! query_response = self.client.query_points( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', query=vectors[0], limit=limit, ) @@ -184,13 +178,11 @@ def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) field_conditions = [] for key, value in filter.items(): field_conditions.append( - models.FieldCondition( - key=f"metadata.{key}", match=models.MatchValue(value=value) - ) + models.FieldCondition(key=f'metadata.{key}', match=models.MatchValue(value=value)) ) points = self.client.scroll( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', scroll_filter=models.Filter(should=field_conditions), limit=limit, ) @@ -202,22 +194,22 @@ def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) def get(self, collection_name: str) -> Optional[GetResult]: # Get all the items in the collection. points = self.client.scroll( - collection_name=f"{self.collection_prefix}_{collection_name}", + collection_name=f'{self.collection_prefix}_{collection_name}', limit=NO_LIMIT, # otherwise qdrant would set limit to 10! ) return self._result_to_get_result(points[0]) def insert(self, collection_name: str, items: list[VectorItem]): # Insert the items into the collection, if the collection does not exist, it will be created. - self._create_collection_if_not_exists(collection_name, len(items[0]["vector"])) + self._create_collection_if_not_exists(collection_name, len(items[0]['vector'])) points = self._create_points(items) - self.client.upload_points(f"{self.collection_prefix}_{collection_name}", points) + self.client.upload_points(f'{self.collection_prefix}_{collection_name}', points) def upsert(self, collection_name: str, items: list[VectorItem]): # Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created. - self._create_collection_if_not_exists(collection_name, len(items[0]["vector"])) + self._create_collection_if_not_exists(collection_name, len(items[0]['vector'])) points = self._create_points(items) - return self.client.upsert(f"{self.collection_prefix}_{collection_name}", points) + return self.client.upsert(f'{self.collection_prefix}_{collection_name}', points) def delete( self, @@ -230,26 +222,28 @@ def delete( if ids: for id_value in ids: - field_conditions.append( - models.FieldCondition( - key="metadata.id", - match=models.MatchValue(value=id_value), + ( + field_conditions.append( + models.FieldCondition( + key='metadata.id', + match=models.MatchValue(value=id_value), + ), ), - ), + ) elif filter: for key, value in filter.items(): - field_conditions.append( - models.FieldCondition( - key=f"metadata.{key}", - match=models.MatchValue(value=value), + ( + field_conditions.append( + models.FieldCondition( + key=f'metadata.{key}', + match=models.MatchValue(value=value), + ), ), - ), + ) return self.client.delete( - collection_name=f"{self.collection_prefix}_{collection_name}", - points_selector=models.FilterSelector( - filter=models.Filter(must=field_conditions) - ), + collection_name=f'{self.collection_prefix}_{collection_name}', + points_selector=models.FilterSelector(filter=models.Filter(must=field_conditions)), ) def reset(self): diff --git a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py index 5ad2ac6929..c3c2ba41d0 100644 --- a/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py +++ b/backend/open_webui/retrieval/vector/dbs/qdrant_multitenancy.py @@ -29,22 +29,18 @@ from qdrant_client.models import models NO_LIMIT = 999999999 -TENANT_ID_FIELD = "tenant_id" +TENANT_ID_FIELD = 'tenant_id' DEFAULT_DIMENSION = 384 log = logging.getLogger(__name__) def _tenant_filter(tenant_id: str) -> models.FieldCondition: - return models.FieldCondition( - key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id) - ) + return models.FieldCondition(key=TENANT_ID_FIELD, match=models.MatchValue(value=tenant_id)) def _metadata_filter(key: str, value: Any) -> models.FieldCondition: - return models.FieldCondition( - key=f"metadata.{key}", match=models.MatchValue(value=value) - ) + return models.FieldCondition(key=f'metadata.{key}', match=models.MatchValue(value=value)) class QdrantClient(VectorDBBase): @@ -59,9 +55,7 @@ def __init__(self): self.QDRANT_HNSW_M = QDRANT_HNSW_M if not self.QDRANT_URI: - raise ValueError( - "QDRANT_URI is not set. Please configure it in the environment variables." - ) + raise ValueError('QDRANT_URI is not set. Please configure it in the environment variables.') # Unified handling for either scheme parsed = urlparse(self.QDRANT_URI) @@ -86,19 +80,19 @@ def __init__(self): ) # Main collection types for multi-tenancy - self.MEMORY_COLLECTION = f"{self.collection_prefix}_memories" - self.KNOWLEDGE_COLLECTION = f"{self.collection_prefix}_knowledge" - self.FILE_COLLECTION = f"{self.collection_prefix}_files" - self.WEB_SEARCH_COLLECTION = f"{self.collection_prefix}_web-search" - self.HASH_BASED_COLLECTION = f"{self.collection_prefix}_hash-based" + self.MEMORY_COLLECTION = f'{self.collection_prefix}_memories' + self.KNOWLEDGE_COLLECTION = f'{self.collection_prefix}_knowledge' + self.FILE_COLLECTION = f'{self.collection_prefix}_files' + self.WEB_SEARCH_COLLECTION = f'{self.collection_prefix}_web-search' + self.HASH_BASED_COLLECTION = f'{self.collection_prefix}_hash-based' def _result_to_get_result(self, points) -> GetResult: ids, documents, metadatas = [], [], [] for point in points: payload = point.payload ids.append(point.id) - documents.append(payload["text"]) - metadatas.append(payload["metadata"]) + documents.append(payload['text']) + metadatas.append(payload['metadata']) return GetResult(ids=[ids], documents=[documents], metadatas=[metadatas]) def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str]: @@ -118,29 +112,25 @@ def _get_collection_and_tenant_id(self, collection_name: str) -> Tuple[str, str] # Check for user memory collections tenant_id = collection_name - if collection_name.startswith("user-memory-"): + if collection_name.startswith('user-memory-'): return self.MEMORY_COLLECTION, tenant_id # Check for file collections - elif collection_name.startswith("file-"): + elif collection_name.startswith('file-'): return self.FILE_COLLECTION, tenant_id # Check for web search collections - elif collection_name.startswith("web-search-"): + elif collection_name.startswith('web-search-'): return self.WEB_SEARCH_COLLECTION, tenant_id # Handle hash-based collections (YouTube and web URLs) - elif len(collection_name) == 63 and all( - c in "0123456789abcdef" for c in collection_name - ): + elif len(collection_name) == 63 and all(c in '0123456789abcdef' for c in collection_name): return self.HASH_BASED_COLLECTION, tenant_id else: return self.KNOWLEDGE_COLLECTION, tenant_id - def _create_multi_tenant_collection( - self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION - ): + def _create_multi_tenant_collection(self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION): """ Creates a collection with multi-tenancy configuration and payload indexes for tenant_id and metadata fields. """ @@ -158,9 +148,7 @@ def _create_multi_tenant_collection( m=0, ), ) - log.info( - f"Multi-tenant collection {mt_collection_name} created with dimension {dimension}!" - ) + log.info(f'Multi-tenant collection {mt_collection_name} created with dimension {dimension}!') self.client.create_payload_index( collection_name=mt_collection_name, @@ -172,7 +160,7 @@ def _create_multi_tenant_collection( ), ) - for field in ("metadata.hash", "metadata.file_id"): + for field in ('metadata.hash', 'metadata.file_id'): self.client.create_payload_index( collection_name=mt_collection_name, field_name=field, @@ -182,28 +170,24 @@ def _create_multi_tenant_collection( ), ) - def _create_points( - self, items: List[VectorItem], tenant_id: str - ) -> List[PointStruct]: + def _create_points(self, items: List[VectorItem], tenant_id: str) -> List[PointStruct]: """ Create point structs from vector items with tenant ID. """ return [ PointStruct( - id=item["id"], - vector=item["vector"], + id=item['id'], + vector=item['vector'], payload={ - "text": item["text"], - "metadata": item["metadata"], + 'text': item['text'], + 'metadata': item['metadata'], TENANT_ID_FIELD: tenant_id, }, ) for item in items ] - def _ensure_collection( - self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION - ): + def _ensure_collection(self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION): """ Ensure the collection exists and payload indexes are created for tenant_id and metadata fields. """ @@ -246,15 +230,13 @@ def delete( must_conditions = [_tenant_filter(tenant_id)] should_conditions = [] if ids: - should_conditions = [_metadata_filter("id", id_value) for id_value in ids] + should_conditions = [_metadata_filter('id', id_value) for id_value in ids] elif filter: must_conditions += [_metadata_filter(k, v) for k, v in filter.items()] return self.client.delete( collection_name=mt_collection, - points_selector=models.FilterSelector( - filter=models.Filter(must=must_conditions, should=should_conditions) - ), + points_selector=models.FilterSelector(filter=models.Filter(must=must_conditions, should=should_conditions)), ) def search( @@ -289,9 +271,7 @@ def search( distances=[[(point.score + 1.0) / 2.0 for point in query_response.points]], ) - def query( - self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None - ): + def query(self, collection_name: str, filter: Dict[str, Any], limit: Optional[int] = None): """ Query points with filters and tenant isolation. """ @@ -338,7 +318,7 @@ def upsert(self, collection_name: str, items: List[VectorItem]): if not self.client or not items: return None mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name) - dimension = len(items[0]["vector"]) + dimension = len(items[0]['vector']) self._ensure_collection(mt_collection, dimension) points = self._create_points(items, tenant_id) self.client.upload_points(mt_collection, points) @@ -372,7 +352,5 @@ def delete_collection(self, collection_name: str): return None self.client.delete( collection_name=mt_collection, - points_selector=models.FilterSelector( - filter=models.Filter(must=[_tenant_filter(tenant_id)]) - ), + points_selector=models.FilterSelector(filter=models.Filter(must=[_tenant_filter(tenant_id)])), ) diff --git a/backend/open_webui/retrieval/vector/dbs/s3vector.py b/backend/open_webui/retrieval/vector/dbs/s3vector.py index 1a30e04e55..8877d206e6 100644 --- a/backend/open_webui/retrieval/vector/dbs/s3vector.py +++ b/backend/open_webui/retrieval/vector/dbs/s3vector.py @@ -28,18 +28,16 @@ def __init__(self): # Simple validation - log warnings instead of raising exceptions if not self.bucket_name: - log.warning("S3_VECTOR_BUCKET_NAME not set - S3Vector will not work") + log.warning('S3_VECTOR_BUCKET_NAME not set - S3Vector will not work') if not self.region: - log.warning("S3_VECTOR_REGION not set - S3Vector will not work") + log.warning('S3_VECTOR_REGION not set - S3Vector will not work') if self.bucket_name and self.region: try: - self.client = boto3.client("s3vectors", region_name=self.region) - log.info( - f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'" - ) + self.client = boto3.client('s3vectors', region_name=self.region) + log.info(f"S3Vector client initialized for bucket '{self.bucket_name}' in region '{self.region}'") except Exception as e: - log.error(f"Failed to initialize S3Vector client: {e}") + log.error(f'Failed to initialize S3Vector client: {e}') self.client = None else: self.client = None @@ -48,8 +46,8 @@ def _create_index( self, index_name: str, dimension: int, - data_type: str = "float32", - distance_metric: str = "cosine", + data_type: str = 'float32', + distance_metric: str = 'cosine', ) -> None: """ Create a new index in the S3 vector bucket for the given collection if it does not exist. @@ -66,21 +64,17 @@ def _create_index( dimension=dimension, distanceMetric=distance_metric, metadataConfiguration={ - "nonFilterableMetadataKeys": [ - "text", + 'nonFilterableMetadataKeys': [ + 'text', ] }, ) - log.info( - f"Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})" - ) + log.info(f'Created S3 index: {index_name} (dim={dimension}, type={data_type}, metric={distance_metric})') except Exception as e: log.error(f"Error creating S3 index '{index_name}': {e}") raise - def _filter_metadata( - self, metadata: Dict[str, Any], item_id: str - ) -> Dict[str, Any]: + def _filter_metadata(self, metadata: Dict[str, Any], item_id: str) -> Dict[str, Any]: """ Filter vector metadata keys to comply with S3 Vector API limit of 10 keys maximum. """ @@ -89,16 +83,16 @@ def _filter_metadata( # Keep only the first 10 keys, prioritizing important ones based on actual Open WebUI metadata important_keys = [ - "text", # The actual document content - "file_id", # File ID - "source", # Document source file - "title", # Document title - "page", # Page number - "total_pages", # Total pages in document - "embedding_config", # Embedding configuration - "created_by", # User who created it - "name", # Document name - "hash", # Content hash + 'text', # The actual document content + 'file_id', # File ID + 'source', # Document source file + 'title', # Document title + 'page', # Page number + 'total_pages', # Total pages in document + 'embedding_config', # Embedding configuration + 'created_by', # User who created it + 'name', # Document name + 'hash', # Content hash ] filtered_metadata = {} @@ -117,9 +111,7 @@ def _filter_metadata( if len(filtered_metadata) >= 10: break - log.warning( - f"Metadata for key '{item_id}' had {len(metadata)} keys, limited to 10 keys" - ) + log.warning(f"Metadata for key '{item_id}' had {len(metadata)} keys, limited to 10 keys") return filtered_metadata def has_collection(self, collection_name: str) -> bool: @@ -128,9 +120,7 @@ def has_collection(self, collection_name: str) -> bool: This avoids pagination issues with list_indexes() and is significantly faster. """ try: - self.client.get_index( - vectorBucketName=self.bucket_name, indexName=collection_name - ) + self.client.get_index(vectorBucketName=self.bucket_name, indexName=collection_name) return True except Exception as e: log.error(f"Error checking if index '{collection_name}' exists: {e}") @@ -142,16 +132,12 @@ def delete_collection(self, collection_name: str) -> None: """ if not self.has_collection(collection_name): - log.warning( - f"Collection '{collection_name}' does not exist, nothing to delete" - ) + log.warning(f"Collection '{collection_name}' does not exist, nothing to delete") return try: log.info(f"Deleting collection '{collection_name}'") - self.client.delete_index( - vectorBucketName=self.bucket_name, indexName=collection_name - ) + self.client.delete_index(vectorBucketName=self.bucket_name, indexName=collection_name) log.info(f"Successfully deleted collection '{collection_name}'") except Exception as e: log.error(f"Error deleting collection '{collection_name}': {e}") @@ -162,10 +148,10 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: Insert vector items into the S3 Vector index. Create index if it does not exist. """ if not items: - log.warning("No items to insert") + log.warning('No items to insert') return - dimension = len(items[0]["vector"]) + dimension = len(items[0]['vector']) try: if not self.has_collection(collection_name): @@ -173,36 +159,36 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: self._create_index( index_name=collection_name, dimension=dimension, - data_type="float32", - distance_metric="cosine", + data_type='float32', + distance_metric='cosine', ) # Prepare vectors for insertion vectors = [] for item in items: # Ensure vector data is in the correct format for S3 Vector API - vector_data = item["vector"] + vector_data = item['vector'] if isinstance(vector_data, list): # Convert list to float32 values as required by S3 Vector API vector_data = [float(x) for x in vector_data] # Prepare metadata, ensuring the text field is preserved - metadata = item.get("metadata", {}).copy() + metadata = item.get('metadata', {}).copy() # Add the text field to metadata so it's available for retrieval - metadata["text"] = item["text"] + metadata['text'] = item['text'] # Convert metadata to string format for consistency metadata = process_metadata(metadata) # Filter metadata to comply with S3 Vector API limit of 10 keys - metadata = self._filter_metadata(metadata, item["id"]) + metadata = self._filter_metadata(metadata, item['id']) vectors.append( { - "key": item["id"], - "data": {"float32": vector_data}, - "metadata": metadata, + 'key': item['id'], + 'data': {'float32': vector_data}, + 'metadata': metadata, } ) @@ -215,15 +201,11 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: indexName=collection_name, vectors=batch, ) - log.info( - f"Inserted batch {i//batch_size + 1}: {len(batch)} vectors into index '{collection_name}'." - ) + log.info(f"Inserted batch {i // batch_size + 1}: {len(batch)} vectors into index '{collection_name}'.") - log.info( - f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'." - ) + log.info(f"Completed insertion of {len(vectors)} vectors into index '{collection_name}'.") except Exception as e: - log.error(f"Error inserting vectors: {e}") + log.error(f'Error inserting vectors: {e}') raise def upsert(self, collection_name: str, items: List[VectorItem]) -> None: @@ -231,49 +213,47 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: Insert or update vector items in the S3 Vector index. Create index if it does not exist. """ if not items: - log.warning("No items to upsert") + log.warning('No items to upsert') return - dimension = len(items[0]["vector"]) - log.info(f"Upsert dimension: {dimension}") + dimension = len(items[0]['vector']) + log.info(f'Upsert dimension: {dimension}') try: if not self.has_collection(collection_name): - log.info( - f"Index '{collection_name}' does not exist. Creating index for upsert." - ) + log.info(f"Index '{collection_name}' does not exist. Creating index for upsert.") self._create_index( index_name=collection_name, dimension=dimension, - data_type="float32", - distance_metric="cosine", + data_type='float32', + distance_metric='cosine', ) # Prepare vectors for upsert vectors = [] for item in items: # Ensure vector data is in the correct format for S3 Vector API - vector_data = item["vector"] + vector_data = item['vector'] if isinstance(vector_data, list): # Convert list to float32 values as required by S3 Vector API vector_data = [float(x) for x in vector_data] # Prepare metadata, ensuring the text field is preserved - metadata = item.get("metadata", {}).copy() + metadata = item.get('metadata', {}).copy() # Add the text field to metadata so it's available for retrieval - metadata["text"] = item["text"] + metadata['text'] = item['text'] # Convert metadata to string format for consistency metadata = process_metadata(metadata) # Filter metadata to comply with S3 Vector API limit of 10 keys - metadata = self._filter_metadata(metadata, item["id"]) + metadata = self._filter_metadata(metadata, item['id']) vectors.append( { - "key": item["id"], - "data": {"float32": vector_data}, - "metadata": metadata, + 'key': item['id'], + 'data': {'float32': vector_data}, + 'metadata': metadata, } ) @@ -283,12 +263,10 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: batch = vectors[i : i + batch_size] if i == 0: # Log sample info for first batch only log.info( - f"Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]['key']}, data_type={type(batch[0]['data']['float32'])}, data_len={len(batch[0]['data']['float32'])}" + f'Upserting batch 1: {len(batch)} vectors. First vector sample: key={batch[0]["key"]}, data_type={type(batch[0]["data"]["float32"])}, data_len={len(batch[0]["data"]["float32"])}' ) else: - log.info( - f"Upserting batch {i//batch_size + 1}: {len(batch)} vectors." - ) + log.info(f'Upserting batch {i // batch_size + 1}: {len(batch)} vectors.') self.client.put_vectors( vectorBucketName=self.bucket_name, @@ -296,11 +274,9 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: vectors=batch, ) - log.info( - f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'." - ) + log.info(f"Completed upsert of {len(vectors)} vectors into index '{collection_name}'.") except Exception as e: - log.error(f"Error upserting vectors: {e}") + log.error(f'Error upserting vectors: {e}') raise def search( @@ -319,13 +295,11 @@ def search( return None if not vectors: - log.warning("No query vectors provided") + log.warning('No query vectors provided') return None try: - log.info( - f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}" - ) + log.info(f"Searching collection '{collection_name}' with {len(vectors)} query vectors, limit={limit}") # Initialize result lists all_ids = [] @@ -335,10 +309,10 @@ def search( # Process each query vector for i, query_vector in enumerate(vectors): - log.debug(f"Processing query vector {i+1}/{len(vectors)}") + log.debug(f'Processing query vector {i + 1}/{len(vectors)}') # Prepare the query vector in S3 Vector format - query_vector_dict = {"float32": [float(x) for x in query_vector]} + query_vector_dict = {'float32': [float(x) for x in query_vector]} # Call S3 Vector query API response = self.client.query_vectors( @@ -356,24 +330,22 @@ def search( query_metadatas = [] query_distances = [] - result_vectors = response.get("vectors", []) + result_vectors = response.get('vectors', []) for vector in result_vectors: - vector_id = vector.get("key") - vector_metadata = vector.get("metadata", {}) - vector_distance = vector.get("distance", 0.0) + vector_id = vector.get('key') + vector_metadata = vector.get('metadata', {}) + vector_distance = vector.get('distance', 0.0) # Extract document text from metadata - document_text = "" + document_text = '' if isinstance(vector_metadata, dict): # Get the text field first (highest priority) - document_text = vector_metadata.get("text") + document_text = vector_metadata.get('text') if not document_text: # Fallback to other possible text fields document_text = ( - vector_metadata.get("content") - or vector_metadata.get("document") - or vector_id + vector_metadata.get('content') or vector_metadata.get('document') or vector_id ) else: document_text = vector_id @@ -389,7 +361,7 @@ def search( all_metadatas.append(query_metadatas) all_distances.append(query_distances) - log.info(f"Search completed. Found results for {len(all_ids)} queries") + log.info(f'Search completed. Found results for {len(all_ids)} queries') # Return SearchResult format return SearchResult( @@ -402,24 +374,20 @@ def search( except Exception as e: log.error(f"Error searching collection '{collection_name}': {str(e)}") # Handle specific AWS exceptions - if hasattr(e, "response") and "Error" in e.response: - error_code = e.response["Error"]["Code"] - if error_code == "NotFoundException": + if hasattr(e, 'response') and 'Error' in e.response: + error_code = e.response['Error']['Code'] + if error_code == 'NotFoundException': log.warning(f"Collection '{collection_name}' not found") return None - elif error_code == "ValidationException": - log.error(f"Invalid query vector dimensions or parameters") + elif error_code == 'ValidationException': + log.error(f'Invalid query vector dimensions or parameters') return None - elif error_code == "AccessDeniedException": - log.error( - f"Access denied for collection '{collection_name}'. Check permissions." - ) + elif error_code == 'AccessDeniedException': + log.error(f"Access denied for collection '{collection_name}'. Check permissions.") return None raise - def query( - self, collection_name: str, filter: Dict, limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: Dict, limit: Optional[int] = None) -> Optional[GetResult]: """ Query vectors from a collection using metadata filter. """ @@ -429,7 +397,7 @@ def query( return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) if not filter: - log.warning("No filter provided, returning all vectors") + log.warning('No filter provided, returning all vectors') return self.get(collection_name) try: @@ -443,17 +411,13 @@ def query( all_vectors_result = self.get(collection_name) if not all_vectors_result or not all_vectors_result.ids: - log.warning("No vectors found in collection") + log.warning('No vectors found in collection') return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) # Extract the lists from the result all_ids = all_vectors_result.ids[0] if all_vectors_result.ids else [] - all_documents = ( - all_vectors_result.documents[0] if all_vectors_result.documents else [] - ) - all_metadatas = ( - all_vectors_result.metadatas[0] if all_vectors_result.metadatas else [] - ) + all_documents = all_vectors_result.documents[0] if all_vectors_result.documents else [] + all_metadatas = all_vectors_result.metadatas[0] if all_vectors_result.metadatas else [] # Apply client-side filtering filtered_ids = [] @@ -472,9 +436,7 @@ def query( if limit and len(filtered_ids) >= limit: break - log.info( - f"Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total" - ) + log.info(f'Filter applied: {len(filtered_ids)} vectors match out of {len(all_ids)} total') # Return GetResult format if filtered_ids: @@ -489,15 +451,13 @@ def query( except Exception as e: log.error(f"Error querying collection '{collection_name}': {str(e)}") # Handle specific AWS exceptions - if hasattr(e, "response") and "Error" in e.response: - error_code = e.response["Error"]["Code"] - if error_code == "NotFoundException": + if hasattr(e, 'response') and 'Error' in e.response: + error_code = e.response['Error']['Code'] + if error_code == 'NotFoundException': log.warning(f"Collection '{collection_name}' not found") return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) - elif error_code == "AccessDeniedException": - log.error( - f"Access denied for collection '{collection_name}'. Check permissions." - ) + elif error_code == 'AccessDeniedException': + log.error(f"Access denied for collection '{collection_name}'. Check permissions.") return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) raise @@ -524,47 +484,43 @@ def get(self, collection_name: str) -> Optional[GetResult]: while True: # Prepare request parameters request_params = { - "vectorBucketName": self.bucket_name, - "indexName": collection_name, - "returnData": False, # Don't include vector data (not needed for get) - "returnMetadata": True, # Include metadata - "maxResults": 500, # Use reasonable page size + 'vectorBucketName': self.bucket_name, + 'indexName': collection_name, + 'returnData': False, # Don't include vector data (not needed for get) + 'returnMetadata': True, # Include metadata + 'maxResults': 500, # Use reasonable page size } if next_token: - request_params["nextToken"] = next_token + request_params['nextToken'] = next_token # Call S3 Vector API response = self.client.list_vectors(**request_params) # Process vectors in this page - vectors = response.get("vectors", []) + vectors = response.get('vectors', []) for vector in vectors: - vector_id = vector.get("key") - vector_data = vector.get("data", {}) - vector_metadata = vector.get("metadata", {}) + vector_id = vector.get('key') + vector_data = vector.get('data', {}) + vector_metadata = vector.get('metadata', {}) # Extract the actual vector array - vector_array = vector_data.get("float32", []) + vector_array = vector_data.get('float32', []) # For documents, we try to extract text from metadata or use the vector ID - document_text = "" + document_text = '' if isinstance(vector_metadata, dict): # Get the text field first (highest priority) - document_text = vector_metadata.get("text") + document_text = vector_metadata.get('text') if not document_text: # Fallback to other possible text fields document_text = ( - vector_metadata.get("content") - or vector_metadata.get("document") - or vector_id + vector_metadata.get('content') or vector_metadata.get('document') or vector_id ) # Log the actual content for debugging - log.debug( - f"Document text preview (first 200 chars): {str(document_text)[:200]}" - ) + log.debug(f'Document text preview (first 200 chars): {str(document_text)[:200]}') else: document_text = vector_id @@ -573,37 +529,29 @@ def get(self, collection_name: str) -> Optional[GetResult]: all_metadatas.append(vector_metadata) # Check if there are more pages - next_token = response.get("nextToken") + next_token = response.get('nextToken') if not next_token: break - log.info( - f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'" - ) + log.info(f"Retrieved {len(all_ids)} vectors from collection '{collection_name}'") # Return in GetResult format # The Open WebUI GetResult expects lists of lists, so we wrap each list if all_ids: - return GetResult( - ids=[all_ids], documents=[all_documents], metadatas=[all_metadatas] - ) + return GetResult(ids=[all_ids], documents=[all_documents], metadatas=[all_metadatas]) else: return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) except Exception as e: - log.error( - f"Error retrieving vectors from collection '{collection_name}': {str(e)}" - ) + log.error(f"Error retrieving vectors from collection '{collection_name}': {str(e)}") # Handle specific AWS exceptions - if hasattr(e, "response") and "Error" in e.response: - error_code = e.response["Error"]["Code"] - if error_code == "NotFoundException": + if hasattr(e, 'response') and 'Error' in e.response: + error_code = e.response['Error']['Code'] + if error_code == 'NotFoundException': log.warning(f"Collection '{collection_name}' not found") return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) - elif error_code == "AccessDeniedException": - log.error( - f"Access denied for collection '{collection_name}'. Check permissions." - ) + elif error_code == 'AccessDeniedException': + log.error(f"Access denied for collection '{collection_name}'. Check permissions.") return GetResult(ids=[[]], documents=[[]], metadatas=[[]]) raise @@ -618,20 +566,16 @@ def delete( """ if not self.has_collection(collection_name): - log.warning( - f"Collection '{collection_name}' does not exist, nothing to delete" - ) + log.warning(f"Collection '{collection_name}' does not exist, nothing to delete") return # Check if this is a knowledge collection (not file-specific) - is_knowledge_collection = not collection_name.startswith("file-") + is_knowledge_collection = not collection_name.startswith('file-') try: if ids: # Delete by specific vector IDs/keys - log.info( - f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'" - ) + log.info(f"Deleting {len(ids)} vectors by IDs from collection '{collection_name}'") self.client.delete_vectors( vectorBucketName=self.bucket_name, indexName=collection_name, @@ -641,15 +585,13 @@ def delete( elif filter: # Handle filter-based deletion - log.info( - f"Deleting vectors by filter from collection '{collection_name}': {filter}" - ) + log.info(f"Deleting vectors by filter from collection '{collection_name}': {filter}") # If this is a knowledge collection and we have a file_id filter, # also clean up the corresponding file-specific collection - if is_knowledge_collection and "file_id" in filter: - file_id = filter["file_id"] - file_collection_name = f"file-{file_id}" + if is_knowledge_collection and 'file_id' in filter: + file_id = filter['file_id'] + file_collection_name = f'file-{file_id}' if self.has_collection(file_collection_name): log.info( f"Found related file-specific collection '{file_collection_name}', deleting it to prevent duplicates" @@ -661,9 +603,7 @@ def delete( query_result = self.query(collection_name, filter) if query_result and query_result.ids and query_result.ids[0]: matching_ids = query_result.ids[0] - log.info( - f"Found {len(matching_ids)} vectors matching filter, deleting them" - ) + log.info(f'Found {len(matching_ids)} vectors matching filter, deleting them') # Delete the matching vectors by ID self.client.delete_vectors( @@ -671,17 +611,13 @@ def delete( indexName=collection_name, keys=matching_ids, ) - log.info( - f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter" - ) + log.info(f"Deleted {len(matching_ids)} vectors from index '{collection_name}' using filter") else: - log.warning("No vectors found matching the filter criteria") + log.warning('No vectors found matching the filter criteria') else: - log.warning("No IDs or filter provided for deletion") + log.warning('No IDs or filter provided for deletion') except Exception as e: - log.error( - f"Error deleting vectors from collection '{collection_name}': {e}" - ) + log.error(f"Error deleting vectors from collection '{collection_name}': {e}") raise def reset(self) -> None: @@ -690,36 +626,32 @@ def reset(self) -> None: """ try: - log.warning( - "Reset called - this will delete all vector indexes in the S3 bucket" - ) + log.warning('Reset called - this will delete all vector indexes in the S3 bucket') # List all indexes response = self.client.list_indexes(vectorBucketName=self.bucket_name) - indexes = response.get("indexes", []) + indexes = response.get('indexes', []) if not indexes: - log.warning("No indexes found to delete") + log.warning('No indexes found to delete') return # Delete all indexes deleted_count = 0 for index in indexes: - index_name = index.get("indexName") + index_name = index.get('indexName') if index_name: try: - self.client.delete_index( - vectorBucketName=self.bucket_name, indexName=index_name - ) + self.client.delete_index(vectorBucketName=self.bucket_name, indexName=index_name) deleted_count += 1 - log.info(f"Deleted index: {index_name}") + log.info(f'Deleted index: {index_name}') except Exception as e: log.error(f"Error deleting index '{index_name}': {e}") - log.info(f"Reset completed: deleted {deleted_count} indexes") + log.info(f'Reset completed: deleted {deleted_count} indexes') except Exception as e: - log.error(f"Error during reset: {e}") + log.error(f'Error during reset: {e}') raise def _matches_filter(self, metadata: Dict[str, Any], filter: Dict[str, Any]) -> bool: @@ -732,15 +664,15 @@ def _matches_filter(self, metadata: Dict[str, Any], filter: Dict[str, Any]) -> b # Check each filter condition for key, expected_value in filter.items(): # Handle special operators - if key.startswith("$"): - if key == "$and": + if key.startswith('$'): + if key == '$and': # All conditions must match if not isinstance(expected_value, list): continue for condition in expected_value: if not self._matches_filter(metadata, condition): return False - elif key == "$or": + elif key == '$or': # At least one condition must match if not isinstance(expected_value, list): continue @@ -760,22 +692,19 @@ def _matches_filter(self, metadata: Dict[str, Any], filter: Dict[str, Any]) -> b if isinstance(expected_value, dict): # Handle comparison operators for op, op_value in expected_value.items(): - if op == "$eq": + if op == '$eq': if actual_value != op_value: return False - elif op == "$ne": + elif op == '$ne': if actual_value == op_value: return False - elif op == "$in": - if ( - not isinstance(op_value, list) - or actual_value not in op_value - ): + elif op == '$in': + if not isinstance(op_value, list) or actual_value not in op_value: return False - elif op == "$nin": + elif op == '$nin': if isinstance(op_value, list) and actual_value in op_value: return False - elif op == "$exists": + elif op == '$exists': if bool(op_value) != (key in metadata): return False # Add more operators as needed diff --git a/backend/open_webui/retrieval/vector/dbs/weaviate.py b/backend/open_webui/retrieval/vector/dbs/weaviate.py index c9b09ad638..2cf4c135c5 100644 --- a/backend/open_webui/retrieval/vector/dbs/weaviate.py +++ b/backend/open_webui/retrieval/vector/dbs/weaviate.py @@ -60,47 +60,43 @@ def __init__(self): try: # Build connection parameters connection_params = { - "http_host": WEAVIATE_HTTP_HOST, - "http_port": WEAVIATE_HTTP_PORT, - "http_secure": WEAVIATE_HTTP_SECURE, - "grpc_host": WEAVIATE_GRPC_HOST, - "grpc_port": WEAVIATE_GRPC_PORT, - "grpc_secure": WEAVIATE_GRPC_SECURE, - "skip_init_checks": WEAVIATE_SKIP_INIT_CHECKS, + 'http_host': WEAVIATE_HTTP_HOST, + 'http_port': WEAVIATE_HTTP_PORT, + 'http_secure': WEAVIATE_HTTP_SECURE, + 'grpc_host': WEAVIATE_GRPC_HOST, + 'grpc_port': WEAVIATE_GRPC_PORT, + 'grpc_secure': WEAVIATE_GRPC_SECURE, + 'skip_init_checks': WEAVIATE_SKIP_INIT_CHECKS, } # Only add auth_credentials if WEAVIATE_API_KEY exists and is not empty if WEAVIATE_API_KEY: - connection_params["auth_credentials"] = ( - weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY) - ) + connection_params['auth_credentials'] = weaviate.classes.init.Auth.api_key(WEAVIATE_API_KEY) self.client = weaviate.connect_to_custom(**connection_params) self.client.connect() except Exception as e: - raise ConnectionError(f"Failed to connect to Weaviate: {e}") from e + raise ConnectionError(f'Failed to connect to Weaviate: {e}') from e def _sanitize_collection_name(self, collection_name: str) -> str: """Sanitize collection name to be a valid Weaviate class name.""" if not isinstance(collection_name, str) or not collection_name.strip(): - raise ValueError("Collection name must be a non-empty string") + raise ValueError('Collection name must be a non-empty string') # Requirements for a valid Weaviate class name: # The collection name must begin with a capital letter. # The name can only contain letters, numbers, and the underscore (_) character. Spaces are not allowed. # Replace hyphens with underscores and keep only alphanumeric characters - name = re.sub(r"[^a-zA-Z0-9_]", "", collection_name.replace("-", "_")) - name = name.strip("_") + name = re.sub(r'[^a-zA-Z0-9_]', '', collection_name.replace('-', '_')) + name = name.strip('_') if not name: - raise ValueError( - "Could not sanitize collection name to be a valid Weaviate class name" - ) + raise ValueError('Could not sanitize collection name to be a valid Weaviate class name') # Ensure it starts with a letter and is capitalized if not name[0].isalpha(): - name = "C" + name + name = 'C' + name return name[0].upper() + name[1:] @@ -118,9 +114,7 @@ def _create_collection(self, collection_name: str) -> None: name=collection_name, vector_config=weaviate.classes.config.Configure.Vectors.self_provided(), properties=[ - weaviate.classes.config.Property( - name="text", data_type=weaviate.classes.config.DataType.TEXT - ), + weaviate.classes.config.Property(name='text', data_type=weaviate.classes.config.DataType.TEXT), ], ) @@ -133,19 +127,15 @@ def insert(self, collection_name: str, items: List[VectorItem]) -> None: with collection.batch.fixed_size(batch_size=100) as batch: for item in items: - item_uuid = str(uuid.uuid4()) if not item["id"] else str(item["id"]) + item_uuid = str(uuid.uuid4()) if not item['id'] else str(item['id']) - properties = {"text": item["text"]} - if item["metadata"]: - clean_metadata = _convert_uuids_to_strings( - process_metadata(item["metadata"]) - ) - clean_metadata.pop("text", None) + properties = {'text': item['text']} + if item['metadata']: + clean_metadata = _convert_uuids_to_strings(process_metadata(item['metadata'])) + clean_metadata.pop('text', None) properties.update(clean_metadata) - batch.add_object( - properties=properties, uuid=item_uuid, vector=item["vector"] - ) + batch.add_object(properties=properties, uuid=item_uuid, vector=item['vector']) def upsert(self, collection_name: str, items: List[VectorItem]) -> None: sane_collection_name = self._sanitize_collection_name(collection_name) @@ -156,19 +146,15 @@ def upsert(self, collection_name: str, items: List[VectorItem]) -> None: with collection.batch.fixed_size(batch_size=100) as batch: for item in items: - item_uuid = str(item["id"]) if item["id"] else None + item_uuid = str(item['id']) if item['id'] else None - properties = {"text": item["text"]} - if item["metadata"]: - clean_metadata = _convert_uuids_to_strings( - process_metadata(item["metadata"]) - ) - clean_metadata.pop("text", None) + properties = {'text': item['text']} + if item['metadata']: + clean_metadata = _convert_uuids_to_strings(process_metadata(item['metadata'])) + clean_metadata.pop('text', None) properties.update(clean_metadata) - batch.add_object( - properties=properties, uuid=item_uuid, vector=item["vector"] - ) + batch.add_object(properties=properties, uuid=item_uuid, vector=item['vector']) def search( self, @@ -205,16 +191,12 @@ def search( for obj in response.objects: properties = dict(obj.properties) if obj.properties else {} - documents.append(properties.pop("text", "")) + documents.append(properties.pop('text', '')) metadatas.append(_convert_uuids_to_strings(properties)) # Weaviate has cosine distance, 2 (worst) -> 0 (best). Re-ordering to 0 -> 1 raw_distances = [ - ( - obj.metadata.distance - if obj.metadata and obj.metadata.distance - else 2.0 - ) + (obj.metadata.distance if obj.metadata and obj.metadata.distance else 2.0) for obj in response.objects ] distances = [(2 - dist) / 2 for dist in raw_distances] @@ -231,16 +213,14 @@ def search( return SearchResult( **{ - "ids": result_ids, - "documents": result_documents, - "metadatas": result_metadatas, - "distances": result_distances, + 'ids': result_ids, + 'documents': result_documents, + 'metadatas': result_metadatas, + 'distances': result_distances, } ) - def query( - self, collection_name: str, filter: Dict, limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: Dict, limit: Optional[int] = None) -> Optional[GetResult]: sane_collection_name = self._sanitize_collection_name(collection_name) if not self.client.collections.exists(sane_collection_name): return None @@ -250,21 +230,15 @@ def query( weaviate_filter = None if filter: for key, value in filter.items(): - prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal( - value - ) + prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal(value) weaviate_filter = ( prop_filter if weaviate_filter is None - else weaviate.classes.query.Filter.all_of( - [weaviate_filter, prop_filter] - ) + else weaviate.classes.query.Filter.all_of([weaviate_filter, prop_filter]) ) try: - response = collection.query.fetch_objects( - filters=weaviate_filter, limit=limit - ) + response = collection.query.fetch_objects(filters=weaviate_filter, limit=limit) ids = [str(obj.uuid) for obj in response.objects] documents = [] @@ -272,14 +246,14 @@ def query( for obj in response.objects: properties = dict(obj.properties) if obj.properties else {} - documents.append(properties.pop("text", "")) + documents.append(properties.pop('text', '')) metadatas.append(_convert_uuids_to_strings(properties)) return GetResult( **{ - "ids": [ids], - "documents": [documents], - "metadatas": [metadatas], + 'ids': [ids], + 'documents': [documents], + 'metadatas': [metadatas], } ) except Exception: @@ -297,7 +271,7 @@ def get(self, collection_name: str) -> Optional[GetResult]: for item in collection.iterator(): ids.append(str(item.uuid)) properties = dict(item.properties) if item.properties else {} - documents.append(properties.pop("text", "")) + documents.append(properties.pop('text', '')) metadatas.append(_convert_uuids_to_strings(properties)) if not ids: @@ -305,9 +279,9 @@ def get(self, collection_name: str) -> Optional[GetResult]: return GetResult( **{ - "ids": [ids], - "documents": [documents], - "metadatas": [metadatas], + 'ids': [ids], + 'documents': [documents], + 'metadatas': [metadatas], } ) except Exception: @@ -332,15 +306,11 @@ def delete( elif filter: weaviate_filter = None for key, value in filter.items(): - prop_filter = weaviate.classes.query.Filter.by_property( - name=key - ).equal(value) + prop_filter = weaviate.classes.query.Filter.by_property(name=key).equal(value) weaviate_filter = ( prop_filter if weaviate_filter is None - else weaviate.classes.query.Filter.all_of( - [weaviate_filter, prop_filter] - ) + else weaviate.classes.query.Filter.all_of([weaviate_filter, prop_filter]) ) if weaviate_filter: diff --git a/backend/open_webui/retrieval/vector/factory.py b/backend/open_webui/retrieval/vector/factory.py index d92b335864..8c0208fd4f 100644 --- a/backend/open_webui/retrieval/vector/factory.py +++ b/backend/open_webui/retrieval/vector/factory.py @@ -8,7 +8,6 @@ class Vector: - @staticmethod def get_vector(vector_type: str) -> VectorDBBase: """ @@ -82,7 +81,7 @@ def get_vector(vector_type: str) -> VectorDBBase: return WeaviateClient() case _: - raise ValueError(f"Unsupported vector type: {vector_type}") + raise ValueError(f'Unsupported vector type: {vector_type}') VECTOR_DB_CLIENT = Vector.get_vector(VECTOR_DB) diff --git a/backend/open_webui/retrieval/vector/main.py b/backend/open_webui/retrieval/vector/main.py index a76fec9956..f7904baa20 100644 --- a/backend/open_webui/retrieval/vector/main.py +++ b/backend/open_webui/retrieval/vector/main.py @@ -63,9 +63,7 @@ def search( pass @abstractmethod - def query( - self, collection_name: str, filter: Dict, limit: Optional[int] = None - ) -> Optional[GetResult]: + def query(self, collection_name: str, filter: Dict, limit: Optional[int] = None) -> Optional[GetResult]: """Query vectors from a collection using metadata filter.""" pass diff --git a/backend/open_webui/retrieval/vector/type.py b/backend/open_webui/retrieval/vector/type.py index df9453aa3e..999aee9c54 100644 --- a/backend/open_webui/retrieval/vector/type.py +++ b/backend/open_webui/retrieval/vector/type.py @@ -2,15 +2,15 @@ class VectorType(StrEnum): - MILVUS = "milvus" - MARIADB_VECTOR = "mariadb-vector" - QDRANT = "qdrant" - CHROMA = "chroma" - PINECONE = "pinecone" - ELASTICSEARCH = "elasticsearch" - OPENSEARCH = "opensearch" - PGVECTOR = "pgvector" - ORACLE23AI = "oracle23ai" - S3VECTOR = "s3vector" - WEAVIATE = "weaviate" - OPENGAUSS = "opengauss" + MILVUS = 'milvus' + MARIADB_VECTOR = 'mariadb-vector' + QDRANT = 'qdrant' + CHROMA = 'chroma' + PINECONE = 'pinecone' + ELASTICSEARCH = 'elasticsearch' + OPENSEARCH = 'opensearch' + PGVECTOR = 'pgvector' + ORACLE23AI = 'oracle23ai' + S3VECTOR = 's3vector' + WEAVIATE = 'weaviate' + OPENGAUSS = 'opengauss' diff --git a/backend/open_webui/retrieval/vector/utils.py b/backend/open_webui/retrieval/vector/utils.py index a39d364419..b2e2fed762 100644 --- a/backend/open_webui/retrieval/vector/utils.py +++ b/backend/open_webui/retrieval/vector/utils.py @@ -1,13 +1,11 @@ from datetime import datetime -KEYS_TO_EXCLUDE = ["content", "pages", "tables", "paragraphs", "sections", "figures"] +KEYS_TO_EXCLUDE = ['content', 'pages', 'tables', 'paragraphs', 'sections', 'figures'] def filter_metadata(metadata: dict[str, any]) -> dict[str, any]: # Removes large/redundant fields from metadata dict. - metadata = { - key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE - } + metadata = {key: value for key, value in metadata.items() if key not in KEYS_TO_EXCLUDE} return metadata diff --git a/backend/open_webui/retrieval/web/azure.py b/backend/open_webui/retrieval/web/azure.py index 3859ccc9b7..4f74ecc982 100644 --- a/backend/open_webui/retrieval/web/azure.py +++ b/backend/open_webui/retrieval/web/azure.py @@ -40,20 +40,17 @@ def search_azure( from azure.search.documents import SearchClient except ImportError: log.error( - "azure-search-documents package is not installed. " - "Install it with: pip install azure-search-documents" + 'azure-search-documents package is not installed. Install it with: pip install azure-search-documents' ) raise ImportError( - "azure-search-documents is required for Azure AI Search. " - "Install it with: pip install azure-search-documents" + 'azure-search-documents is required for Azure AI Search. ' + 'Install it with: pip install azure-search-documents' ) try: # Create search client with API key authentication credential = AzureKeyCredential(api_key) - search_client = SearchClient( - endpoint=endpoint, index_name=index_name, credential=credential - ) + search_client = SearchClient(endpoint=endpoint, index_name=index_name, credential=credential) # Perform the search results = search_client.search(search_text=query, top=count) @@ -68,42 +65,42 @@ def search_azure( # Try to find URL field (common names) link = ( - result_dict.get("url") - or result_dict.get("link") - or result_dict.get("uri") - or result_dict.get("metadata_storage_path") - or "" + result_dict.get('url') + or result_dict.get('link') + or result_dict.get('uri') + or result_dict.get('metadata_storage_path') + or '' ) # Try to find title field (common names) title = ( - result_dict.get("title") - or result_dict.get("name") - or result_dict.get("metadata_title") - or result_dict.get("metadata_storage_name") + result_dict.get('title') + or result_dict.get('name') + or result_dict.get('metadata_title') + or result_dict.get('metadata_storage_name') or None ) # Try to find content/snippet field (common names) snippet = ( - result_dict.get("content") - or result_dict.get("snippet") - or result_dict.get("description") - or result_dict.get("summary") - or result_dict.get("text") + result_dict.get('content') + or result_dict.get('snippet') + or result_dict.get('description') + or result_dict.get('summary') + or result_dict.get('text') or None ) # Truncate snippet if too long if snippet and len(snippet) > 500: - snippet = snippet[:497] + "..." + snippet = snippet[:497] + '...' if link: # Only add if we found a valid link search_results.append( { - "link": link, - "title": title, - "snippet": snippet, + 'link': link, + 'title': title, + 'snippet': snippet, } ) @@ -114,13 +111,13 @@ def search_azure( # Convert to SearchResult objects return [ SearchResult( - link=result["link"], - title=result.get("title"), - snippet=result.get("snippet"), + link=result['link'], + title=result.get('title'), + snippet=result.get('snippet'), ) for result in search_results ] except Exception as ex: - log.error(f"Azure AI Search error: {ex}") + log.error(f'Azure AI Search error: {ex}') raise ex diff --git a/backend/open_webui/retrieval/web/bing.py b/backend/open_webui/retrieval/web/bing.py index 4c9822b900..b7cfea89de 100644 --- a/backend/open_webui/retrieval/web/bing.py +++ b/backend/open_webui/retrieval/web/bing.py @@ -21,48 +21,44 @@ def search_bing( filter_list: Optional[list[str]] = None, ) -> list[SearchResult]: mkt = locale - params = {"q": query, "mkt": mkt, "count": count} - headers = {"Ocp-Apim-Subscription-Key": subscription_key} + params = {'q': query, 'mkt': mkt, 'count': count} + headers = {'Ocp-Apim-Subscription-Key': subscription_key} try: response = requests.get(endpoint, headers=headers, params=params) response.raise_for_status() json_response = response.json() - results = json_response.get("webPages", {}).get("value", []) + results = json_response.get('webPages', {}).get('value', []) if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["url"], - title=result.get("name"), - snippet=result.get("snippet"), + link=result['url'], + title=result.get('name'), + snippet=result.get('snippet'), ) for result in results ] except Exception as ex: - log.error(f"Error: {ex}") + log.error(f'Error: {ex}') raise ex def main(): - parser = argparse.ArgumentParser(description="Search Bing from the command line.") + parser = argparse.ArgumentParser(description='Search Bing from the command line.') parser.add_argument( - "query", + 'query', type=str, - default="Top 10 international news today", - help="The search query.", + default='Top 10 international news today', + help='The search query.', ) + parser.add_argument('--count', type=int, default=10, help='Number of search results to return.') + parser.add_argument('--filter', nargs='*', help='List of filters to apply to the search results.') parser.add_argument( - "--count", type=int, default=10, help="Number of search results to return." - ) - parser.add_argument( - "--filter", nargs="*", help="List of filters to apply to the search results." - ) - parser.add_argument( - "--locale", + '--locale', type=str, - default="en-US", - help="The locale to use for the search, maps to market in api", + default='en-US', + help='The locale to use for the search, maps to market in api', ) args = parser.parse_args() diff --git a/backend/open_webui/retrieval/web/bocha.py b/backend/open_webui/retrieval/web/bocha.py index 7e3c9b0a40..3557dcffb9 100644 --- a/backend/open_webui/retrieval/web/bocha.py +++ b/backend/open_webui/retrieval/web/bocha.py @@ -10,43 +10,38 @@ def _parse_response(response): results = [] - if "data" in response: - data = response["data"] - if "webPages" in data: - webPages = data["webPages"] - if "value" in webPages: + if 'data' in response: + data = response['data'] + if 'webPages' in data: + webPages = data['webPages'] + if 'value' in webPages: results = [ { - "id": item.get("id", ""), - "name": item.get("name", ""), - "url": item.get("url", ""), - "snippet": item.get("snippet", ""), - "summary": item.get("summary", ""), - "siteName": item.get("siteName", ""), - "siteIcon": item.get("siteIcon", ""), - "datePublished": item.get("datePublished", "") - or item.get("dateLastCrawled", ""), + 'id': item.get('id', ''), + 'name': item.get('name', ''), + 'url': item.get('url', ''), + 'snippet': item.get('snippet', ''), + 'summary': item.get('summary', ''), + 'siteName': item.get('siteName', ''), + 'siteIcon': item.get('siteIcon', ''), + 'datePublished': item.get('datePublished', '') or item.get('dateLastCrawled', ''), } - for item in webPages["value"] + for item in webPages['value'] ] return results -def search_bocha( - api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None -) -> list[SearchResult]: +def search_bocha(api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None) -> list[SearchResult]: """Search using Bocha's Search API and return the results as a list of SearchResult objects. Args: api_key (str): A Bocha Search API key query (str): The query to search for """ - url = "https://api.bochaai.com/v1/web-search?utm_source=ollama" - headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + url = 'https://api.bochaai.com/v1/web-search?utm_source=ollama' + headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'} - payload = json.dumps( - {"query": query, "summary": True, "freshness": "noLimit", "count": count} - ) + payload = json.dumps({'query': query, 'summary': True, 'freshness': 'noLimit', 'count': count}) response = requests.post(url, headers=headers, data=payload, timeout=5) response.raise_for_status() @@ -56,8 +51,6 @@ def search_bocha( results = get_filtered_results(results, filter_list) return [ - SearchResult( - link=result["url"], title=result.get("name"), snippet=result.get("summary") - ) + SearchResult(link=result['url'], title=result.get('name'), snippet=result.get('summary')) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/brave.py b/backend/open_webui/retrieval/web/brave.py index 49c8a88e81..9e663c2684 100644 --- a/backend/open_webui/retrieval/web/brave.py +++ b/backend/open_webui/retrieval/web/brave.py @@ -8,44 +8,42 @@ log = logging.getLogger(__name__) -def search_brave( - api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None -) -> list[SearchResult]: +def search_brave(api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None) -> list[SearchResult]: """Search using Brave's Search API and return the results as a list of SearchResult objects. Args: api_key (str): A Brave Search API key query (str): The query to search for """ - url = "https://api.search.brave.com/res/v1/web/search" + url = 'https://api.search.brave.com/res/v1/web/search' headers = { - "Accept": "application/json", - "Accept-Encoding": "gzip", - "X-Subscription-Token": api_key, + 'Accept': 'application/json', + 'Accept-Encoding': 'gzip', + 'X-Subscription-Token': api_key, } - params = {"q": query, "count": count} + params = {'q': query, 'count': count} response = requests.get(url, headers=headers, params=params) # Handle 429 rate limiting - Brave free tier allows 1 request/second # If rate limited, wait 1 second and retry once before failing if response.status_code == 429: - log.info("Brave Search API rate limited (429), retrying after 1 second...") + log.info('Brave Search API rate limited (429), retrying after 1 second...') time.sleep(1) response = requests.get(url, headers=headers, params=params) response.raise_for_status() json_response = response.json() - results = json_response.get("web", {}).get("results", []) + results = json_response.get('web', {}).get('results', []) if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["url"], - title=result.get("title"), - snippet=result.get("description"), + link=result['url'], + title=result.get('title'), + snippet=result.get('description'), ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/duckduckgo.py b/backend/open_webui/retrieval/web/duckduckgo.py index 7528418cdb..da1c3f77ec 100644 --- a/backend/open_webui/retrieval/web/duckduckgo.py +++ b/backend/open_webui/retrieval/web/duckduckgo.py @@ -13,7 +13,7 @@ def search_duckduckgo( count: int, filter_list: Optional[list[str]] = None, concurrent_requests: Optional[int] = None, - backend: Optional[str] = "auto", + backend: Optional[str] = 'auto', ) -> list[SearchResult]: """ Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects. @@ -33,20 +33,18 @@ def search_duckduckgo( # Use the ddgs.text() method to perform the search try: - search_results = ddgs.text( - query, safesearch="moderate", max_results=count, backend=backend - ) + search_results = ddgs.text(query, safesearch='moderate', max_results=count, backend=backend) except RatelimitException as e: - log.error(f"RatelimitException: {e}") + log.error(f'RatelimitException: {e}') if filter_list: search_results = get_filtered_results(search_results, filter_list) # Return the list of search results return [ SearchResult( - link=result["href"], - title=result.get("title"), - snippet=result.get("body"), + link=result['href'], + title=result.get('title'), + snippet=result.get('body'), ) for result in search_results ] diff --git a/backend/open_webui/retrieval/web/exa.py b/backend/open_webui/retrieval/web/exa.py index df9554fab2..860917854e 100644 --- a/backend/open_webui/retrieval/web/exa.py +++ b/backend/open_webui/retrieval/web/exa.py @@ -7,7 +7,7 @@ log = logging.getLogger(__name__) -EXA_API_BASE = "https://api.exa.ai" +EXA_API_BASE = 'https://api.exa.ai' @dataclass @@ -31,36 +31,34 @@ def search_exa( count (int): Number of results to return filter_list (Optional[list[str]]): List of domains to filter results by """ - log.info(f"Searching with Exa for query: {query}") + log.info(f'Searching with Exa for query: {query}') - headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'} payload = { - "query": query, - "numResults": count or 5, - "includeDomains": filter_list, - "contents": {"text": True, "highlights": True}, - "type": "auto", # Use the auto search type (keyword or neural) + 'query': query, + 'numResults': count or 5, + 'includeDomains': filter_list, + 'contents': {'text': True, 'highlights': True}, + 'type': 'auto', # Use the auto search type (keyword or neural) } try: - response = requests.post( - f"{EXA_API_BASE}/search", headers=headers, json=payload - ) + response = requests.post(f'{EXA_API_BASE}/search', headers=headers, json=payload) response.raise_for_status() data = response.json() results = [] - for result in data["results"]: + for result in data['results']: results.append( ExaResult( - url=result["url"], - title=result["title"], - text=result["text"], + url=result['url'], + title=result['title'], + text=result['text'], ) ) - log.info(f"Found {len(results)} results") + log.info(f'Found {len(results)} results') return [ SearchResult( link=result.url, @@ -70,5 +68,5 @@ def search_exa( for result in results ] except Exception as e: - log.error(f"Error searching Exa: {e}") + log.error(f'Error searching Exa: {e}') return [] diff --git a/backend/open_webui/retrieval/web/external.py b/backend/open_webui/retrieval/web/external.py index e8cf72e9f0..7f5a2bf2af 100644 --- a/backend/open_webui/retrieval/web/external.py +++ b/backend/open_webui/retrieval/web/external.py @@ -24,12 +24,12 @@ def search_external( ) -> List[SearchResult]: try: headers = { - "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", - "Authorization": f"Bearer {external_api_key}", + 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot', + 'Authorization': f'Bearer {external_api_key}', } headers = include_user_info_headers(headers, user) - chat_id = getattr(request.state, "chat_id", None) + chat_id = getattr(request.state, 'chat_id', None) if chat_id: headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id) @@ -37,8 +37,8 @@ def search_external( external_url, headers=headers, json={ - "query": query, - "count": count, + 'query': query, + 'count': count, }, ) response.raise_for_status() @@ -47,14 +47,14 @@ def search_external( results = get_filtered_results(results, filter_list) results = [ SearchResult( - link=result.get("link"), - title=result.get("title"), - snippet=result.get("snippet"), + link=result.get('link'), + title=result.get('title'), + snippet=result.get('snippet'), ) for result in results[:count] ] - log.info(f"External search results: {results}") + log.info(f'External search results: {results}') return results except Exception as e: - log.error(f"Error in External search: {e}") + log.error(f'Error in External search: {e}') return [] diff --git a/backend/open_webui/retrieval/web/firecrawl.py b/backend/open_webui/retrieval/web/firecrawl.py index e6e96992a1..4bb23e3797 100644 --- a/backend/open_webui/retrieval/web/firecrawl.py +++ b/backend/open_webui/retrieval/web/firecrawl.py @@ -17,9 +17,7 @@ def search_firecrawl( from firecrawl import FirecrawlApp firecrawl = FirecrawlApp(api_key=firecrawl_api_key, api_url=firecrawl_url) - response = firecrawl.search( - query=query, limit=count, ignore_invalid_urls=True, timeout=count * 3 - ) + response = firecrawl.search(query=query, limit=count, ignore_invalid_urls=True, timeout=count * 3) results = response.web if filter_list: results = get_filtered_results(results, filter_list) @@ -31,8 +29,8 @@ def search_firecrawl( ) for result in results[:count] ] - log.info(f"External search results: {results}") + log.info(f'External search results: {results}') return results except Exception as e: - log.error(f"Error in External search: {e}") + log.error(f'Error in External search: {e}') return [] diff --git a/backend/open_webui/retrieval/web/google_pse.py b/backend/open_webui/retrieval/web/google_pse.py index 96fa8c98cd..bb0a852658 100644 --- a/backend/open_webui/retrieval/web/google_pse.py +++ b/backend/open_webui/retrieval/web/google_pse.py @@ -28,11 +28,11 @@ def search_google_pse( Returns: list[SearchResult]: A list of SearchResult objects. """ - url = "https://www.googleapis.com/customsearch/v1" + url = 'https://www.googleapis.com/customsearch/v1' - headers = {"Content-Type": "application/json"} + headers = {'Content-Type': 'application/json'} if referer: - headers["Referer"] = referer + headers['Referer'] = referer all_results = [] start_index = 1 # Google PSE start parameter is 1-based @@ -40,21 +40,19 @@ def search_google_pse( while count > 0: num_results_this_page = min(count, 10) # Google PSE max results per page is 10 params = { - "cx": search_engine_id, - "q": query, - "key": api_key, - "num": num_results_this_page, - "start": start_index, + 'cx': search_engine_id, + 'q': query, + 'key': api_key, + 'num': num_results_this_page, + 'start': start_index, } - response = requests.request("GET", url, headers=headers, params=params) + response = requests.request('GET', url, headers=headers, params=params) response.raise_for_status() json_response = response.json() - results = json_response.get("items", []) + results = json_response.get('items', []) if results: # check if results are returned. If not, no more pages to fetch. all_results.extend(results) - count -= len( - results - ) # Decrement count by the number of results fetched in this page. + count -= len(results) # Decrement count by the number of results fetched in this page. start_index += 10 # Increment start index for the next page else: break # No more results from Google PSE, break the loop @@ -64,9 +62,9 @@ def search_google_pse( return [ SearchResult( - link=result["link"], - title=result.get("title"), - snippet=result.get("snippet"), + link=result['link'], + title=result.get('title'), + snippet=result.get('snippet'), ) for result in all_results ] diff --git a/backend/open_webui/retrieval/web/jina_search.py b/backend/open_webui/retrieval/web/jina_search.py index d1168bb36f..b3266c47d0 100644 --- a/backend/open_webui/retrieval/web/jina_search.py +++ b/backend/open_webui/retrieval/web/jina_search.py @@ -7,9 +7,7 @@ log = logging.getLogger(__name__) -def search_jina( - api_key: str, query: str, count: int, base_url: str = "" -) -> list[SearchResult]: +def search_jina(api_key: str, query: str, count: int, base_url: str = '') -> list[SearchResult]: """ Search using Jina's Search API and return the results as a list of SearchResult objects. Args: @@ -21,16 +19,16 @@ def search_jina( Returns: list[SearchResult]: A list of search results """ - jina_search_endpoint = base_url if base_url else "https://s.jina.ai/" + jina_search_endpoint = base_url if base_url else 'https://s.jina.ai/' headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": api_key, - "X-Retain-Images": "none", + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': api_key, + 'X-Retain-Images': 'none', } - payload = {"q": query, "count": count if count <= 10 else 10} + payload = {'q': query, 'count': count if count <= 10 else 10} url = str(URL(jina_search_endpoint)) response = requests.post(url, headers=headers, json=payload) @@ -38,12 +36,12 @@ def search_jina( data = response.json() results = [] - for result in data["data"]: + for result in data['data']: results.append( SearchResult( - link=result["url"], - title=result.get("title"), - snippet=result.get("content"), + link=result['url'], + title=result.get('title'), + snippet=result.get('content'), ) ) diff --git a/backend/open_webui/retrieval/web/kagi.py b/backend/open_webui/retrieval/web/kagi.py index f0303acf69..e6ed570011 100644 --- a/backend/open_webui/retrieval/web/kagi.py +++ b/backend/open_webui/retrieval/web/kagi.py @@ -7,9 +7,7 @@ log = logging.getLogger(__name__) -def search_kagi( - api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None -) -> list[SearchResult]: +def search_kagi(api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None) -> list[SearchResult]: """Search using Kagi's Search API and return the results as a list of SearchResult objects. The Search API will inherit the settings in your account, including results personalization and snippet length. @@ -19,23 +17,21 @@ def search_kagi( query (str): The query to search for count (int): The number of results to return """ - url = "https://kagi.com/api/v0/search" + url = 'https://kagi.com/api/v0/search' headers = { - "Authorization": f"Bot {api_key}", + 'Authorization': f'Bot {api_key}', } - params = {"q": query, "limit": count} + params = {'q': query, 'limit': count} response = requests.get(url, headers=headers, params=params) response.raise_for_status() json_response = response.json() - search_results = json_response.get("data", []) + search_results = json_response.get('data', []) results = [ - SearchResult( - link=result["url"], title=result["title"], snippet=result.get("snippet") - ) + SearchResult(link=result['url'], title=result['title'], snippet=result.get('snippet')) for result in search_results - if result["t"] == 0 + if result['t'] == 0 ] print(results) diff --git a/backend/open_webui/retrieval/web/main.py b/backend/open_webui/retrieval/web/main.py index 1b8df9f8ee..3a8fed52dd 100644 --- a/backend/open_webui/retrieval/web/main.py +++ b/backend/open_webui/retrieval/web/main.py @@ -16,7 +16,7 @@ def get_filtered_results(results, filter_list): filtered_results = [] for result in results: - url = result.get("url") or result.get("link", "") or result.get("href", "") + url = result.get('url') or result.get('link', '') or result.get('href', '') if not validators.url(url): continue diff --git a/backend/open_webui/retrieval/web/mojeek.py b/backend/open_webui/retrieval/web/mojeek.py index d48f7aeef8..a094ef6fc8 100644 --- a/backend/open_webui/retrieval/web/mojeek.py +++ b/backend/open_webui/retrieval/web/mojeek.py @@ -7,32 +7,27 @@ log = logging.getLogger(__name__) -def search_mojeek( - api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None -) -> list[SearchResult]: +def search_mojeek(api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None) -> list[SearchResult]: """Search using Mojeek's Search API and return the results as a list of SearchResult objects. Args: api_key (str): A Mojeek Search API key query (str): The query to search for """ - url = "https://api.mojeek.com/search" + url = 'https://api.mojeek.com/search' headers = { - "Accept": "application/json", + 'Accept': 'application/json', } - params = {"q": query, "api_key": api_key, "fmt": "json", "t": count} + params = {'q': query, 'api_key': api_key, 'fmt': 'json', 't': count} response = requests.get(url, headers=headers, params=params) response.raise_for_status() json_response = response.json() - results = json_response.get("response", {}).get("results", []) + results = json_response.get('response', {}).get('results', []) print(results) if filter_list: results = get_filtered_results(results, filter_list) return [ - SearchResult( - link=result["url"], title=result.get("title"), snippet=result.get("desc") - ) - for result in results + SearchResult(link=result['url'], title=result.get('title'), snippet=result.get('desc')) for result in results ] diff --git a/backend/open_webui/retrieval/web/ollama.py b/backend/open_webui/retrieval/web/ollama.py index 71bd9d5124..7ed19b91b1 100644 --- a/backend/open_webui/retrieval/web/ollama.py +++ b/backend/open_webui/retrieval/web/ollama.py @@ -23,30 +23,30 @@ def search_ollama_cloud( count (int): Number of results to return filter_list (Optional[list[str]]): List of domains to filter results by """ - log.info(f"Searching with Ollama for query: {query}") + log.info(f'Searching with Ollama for query: {query}') - headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} - payload = {"query": query, "max_results": count} + headers = {'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'} + payload = {'query': query, 'max_results': count} try: - response = requests.post(f"{url}/api/web_search", headers=headers, json=payload) + response = requests.post(f'{url}/api/web_search', headers=headers, json=payload) response.raise_for_status() data = response.json() - results = data.get("results", []) - log.info(f"Found {len(results)} results") + results = data.get('results', []) + log.info(f'Found {len(results)} results') if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result.get("url", ""), - title=result.get("title", ""), - snippet=result.get("content", ""), + link=result.get('url', ''), + title=result.get('title', ''), + snippet=result.get('content', ''), ) for result in results ] except Exception as e: - log.error(f"Error searching Ollama: {e}") + log.error(f'Error searching Ollama: {e}') return [] diff --git a/backend/open_webui/retrieval/web/perplexity.py b/backend/open_webui/retrieval/web/perplexity.py index aae802b432..8b3a9d3b08 100644 --- a/backend/open_webui/retrieval/web/perplexity.py +++ b/backend/open_webui/retrieval/web/perplexity.py @@ -5,13 +5,13 @@ from open_webui.retrieval.web.main import SearchResult, get_filtered_results MODELS = Literal[ - "sonar", - "sonar-pro", - "sonar-reasoning", - "sonar-reasoning-pro", - "sonar-deep-research", + 'sonar', + 'sonar-pro', + 'sonar-reasoning', + 'sonar-reasoning-pro', + 'sonar-deep-research', ] -SEARCH_CONTEXT_USAGE_LEVELS = Literal["low", "medium", "high"] +SEARCH_CONTEXT_USAGE_LEVELS = Literal['low', 'medium', 'high'] log = logging.getLogger(__name__) @@ -22,8 +22,8 @@ def search_perplexity( query: str, count: int, filter_list: Optional[list[str]] = None, - model: MODELS = "sonar", - search_context_usage: SEARCH_CONTEXT_USAGE_LEVELS = "medium", + model: MODELS = 'sonar', + search_context_usage: SEARCH_CONTEXT_USAGE_LEVELS = 'medium', ) -> list[SearchResult]: """Search using Perplexity API and return the results as a list of SearchResult objects. @@ -38,66 +38,63 @@ def search_perplexity( """ # Handle PersistentConfig object - if hasattr(api_key, "__str__"): + if hasattr(api_key, '__str__'): api_key = str(api_key) try: - url = "https://api.perplexity.ai/chat/completions" + url = 'https://api.perplexity.ai/chat/completions' # Create payload for the API call payload = { - "model": model, - "messages": [ + 'model': model, + 'messages': [ { - "role": "system", - "content": "You are a search assistant. Provide factual information with citations.", + 'role': 'system', + 'content': 'You are a search assistant. Provide factual information with citations.', }, - {"role": "user", "content": query}, + {'role': 'user', 'content': query}, ], - "temperature": 0.2, # Lower temperature for more factual responses - "stream": False, - "web_search_options": { - "search_context_usage": search_context_usage, + 'temperature': 0.2, # Lower temperature for more factual responses + 'stream': False, + 'web_search_options': { + 'search_context_usage': search_context_usage, }, } headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', } # Make the API request - response = requests.request("POST", url, json=payload, headers=headers) + response = requests.request('POST', url, json=payload, headers=headers) # Parse the JSON response json_response = response.json() # Extract citations from the response - citations = json_response.get("citations", []) + citations = json_response.get('citations', []) # Create search results from citations results = [] for i, citation in enumerate(citations[:count]): # Extract content from the response to use as snippet - content = "" - if "choices" in json_response and json_response["choices"]: + content = '' + if 'choices' in json_response and json_response['choices']: if i == 0: - content = json_response["choices"][0]["message"]["content"] + content = json_response['choices'][0]['message']['content'] - result = {"link": citation, "title": f"Source {i+1}", "snippet": content} + result = {'link': citation, 'title': f'Source {i + 1}', 'snippet': content} results.append(result) if filter_list: - results = get_filtered_results(results, filter_list) return [ - SearchResult( - link=result["link"], title=result["title"], snippet=result["snippet"] - ) + SearchResult(link=result['link'], title=result['title'], snippet=result['snippet']) for result in results[:count] ] except Exception as e: - log.error(f"Error searching with Perplexity API: {e}") + log.error(f'Error searching with Perplexity API: {e}') return [] diff --git a/backend/open_webui/retrieval/web/perplexity_search.py b/backend/open_webui/retrieval/web/perplexity_search.py index 744a505c05..9cbec049d9 100644 --- a/backend/open_webui/retrieval/web/perplexity_search.py +++ b/backend/open_webui/retrieval/web/perplexity_search.py @@ -13,7 +13,7 @@ def search_perplexity_search( query: str, count: int, filter_list: Optional[list[str]] = None, - api_url: str = "https://api.perplexity.ai/search", + api_url: str = 'https://api.perplexity.ai/search', user=None, ) -> list[SearchResult]: """Search using Perplexity API and return the results as a list of SearchResult objects. @@ -29,10 +29,10 @@ def search_perplexity_search( """ # Handle PersistentConfig object - if hasattr(api_key, "__str__"): + if hasattr(api_key, '__str__'): api_key = str(api_key) - if hasattr(api_url, "__str__"): + if hasattr(api_url, '__str__'): api_url = str(api_url) try: @@ -40,13 +40,13 @@ def search_perplexity_search( # Create payload for the API call payload = { - "query": query, - "max_results": count, + 'query': query, + 'max_results': count, } headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', } # Forward user info headers if user is provided @@ -54,20 +54,17 @@ def search_perplexity_search( headers = include_user_info_headers(headers, user) # Make the API request - response = requests.request("POST", url, json=payload, headers=headers) + response = requests.request('POST', url, json=payload, headers=headers) # Parse the JSON response json_response = response.json() # Extract citations from the response - results = json_response.get("results", []) + results = json_response.get('results', []) return [ - SearchResult( - link=result["url"], title=result["title"], snippet=result["snippet"] - ) - for result in results + SearchResult(link=result['url'], title=result['title'], snippet=result['snippet']) for result in results ] except Exception as e: - log.error(f"Error searching with Perplexity Search API: {e}") + log.error(f'Error searching with Perplexity Search API: {e}') return [] diff --git a/backend/open_webui/retrieval/web/searchapi.py b/backend/open_webui/retrieval/web/searchapi.py index caf781c5df..855269ef02 100644 --- a/backend/open_webui/retrieval/web/searchapi.py +++ b/backend/open_webui/retrieval/web/searchapi.py @@ -21,28 +21,26 @@ def search_searchapi( api_key (str): A searchapi.io API key query (str): The query to search for """ - url = "https://www.searchapi.io/api/v1/search" + url = 'https://www.searchapi.io/api/v1/search' - engine = engine or "google" + engine = engine or 'google' - payload = {"engine": engine, "q": query, "api_key": api_key} + payload = {'engine': engine, 'q': query, 'api_key': api_key} - url = f"{url}?{urlencode(payload)}" - response = requests.request("GET", url) + url = f'{url}?{urlencode(payload)}' + response = requests.request('GET', url) json_response = response.json() - log.info(f"results from searchapi search: {json_response}") + log.info(f'results from searchapi search: {json_response}') - results = sorted( - json_response.get("organic_results", []), key=lambda x: x.get("position", 0) - ) + results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0)) if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["link"], - title=result.get("title"), - snippet=result.get("snippet"), + link=result['link'], + title=result.get('title'), + snippet=result.get('snippet'), ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/searxng.py b/backend/open_webui/retrieval/web/searxng.py index b3d4eb8795..0335bea9a3 100644 --- a/backend/open_webui/retrieval/web/searxng.py +++ b/backend/open_webui/retrieval/web/searxng.py @@ -38,38 +38,38 @@ def search_searxng( """ # Default values for optional parameters are provided as empty strings or None when not specified. - language = kwargs.get("language", "all") - safesearch = kwargs.get("safesearch", "1") - time_range = kwargs.get("time_range", "") - categories = "".join(kwargs.get("categories", [])) + language = kwargs.get('language', 'all') + safesearch = kwargs.get('safesearch', '1') + time_range = kwargs.get('time_range', '') + categories = ''.join(kwargs.get('categories', [])) params = { - "q": query, - "format": "json", - "pageno": 1, - "safesearch": safesearch, - "language": language, - "time_range": time_range, - "categories": categories, - "theme": "simple", - "image_proxy": 0, + 'q': query, + 'format': 'json', + 'pageno': 1, + 'safesearch': safesearch, + 'language': language, + 'time_range': time_range, + 'categories': categories, + 'theme': 'simple', + 'image_proxy': 0, } # Legacy query format - if "" in query_url: + if '' in query_url: # Strip all query parameters from the URL - query_url = query_url.split("?")[0] + query_url = query_url.split('?')[0] - log.debug(f"searching {query_url}") + log.debug(f'searching {query_url}') response = requests.get( query_url, headers={ - "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", - "Accept": "text/html", - "Accept-Encoding": "gzip, deflate", - "Accept-Language": "en-US,en;q=0.5", - "Connection": "keep-alive", + 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot', + 'Accept': 'text/html', + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', }, params=params, ) @@ -77,13 +77,11 @@ def search_searxng( response.raise_for_status() # Raise an exception for HTTP errors. json_response = response.json() - results = json_response.get("results", []) - sorted_results = sorted(results, key=lambda x: x.get("score", 0), reverse=True) + results = json_response.get('results', []) + sorted_results = sorted(results, key=lambda x: x.get('score', 0), reverse=True) if filter_list: sorted_results = get_filtered_results(sorted_results, filter_list) return [ - SearchResult( - link=result["url"], title=result.get("title"), snippet=result.get("content") - ) + SearchResult(link=result['url'], title=result.get('title'), snippet=result.get('content')) for result in sorted_results[:count] ] diff --git a/backend/open_webui/retrieval/web/serpapi.py b/backend/open_webui/retrieval/web/serpapi.py index bb421b500f..602f60d7a7 100644 --- a/backend/open_webui/retrieval/web/serpapi.py +++ b/backend/open_webui/retrieval/web/serpapi.py @@ -21,28 +21,26 @@ def search_serpapi( api_key (str): A serpapi.com API key query (str): The query to search for """ - url = "https://serpapi.com/search" + url = 'https://serpapi.com/search' - engine = engine or "google" + engine = engine or 'google' - payload = {"engine": engine, "q": query, "api_key": api_key} + payload = {'engine': engine, 'q': query, 'api_key': api_key} - url = f"{url}?{urlencode(payload)}" - response = requests.request("GET", url) + url = f'{url}?{urlencode(payload)}' + response = requests.request('GET', url) json_response = response.json() - log.info(f"results from serpapi search: {json_response}") + log.info(f'results from serpapi search: {json_response}') - results = sorted( - json_response.get("organic_results", []), key=lambda x: x.get("position", 0) - ) + results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0)) if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["link"], - title=result.get("title"), - snippet=result.get("snippet"), + link=result['link'], + title=result.get('title'), + snippet=result.get('snippet'), ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/serper.py b/backend/open_webui/retrieval/web/serper.py index 5a745e304e..9f1a8e1b3a 100644 --- a/backend/open_webui/retrieval/web/serper.py +++ b/backend/open_webui/retrieval/web/serper.py @@ -8,34 +8,30 @@ log = logging.getLogger(__name__) -def search_serper( - api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None -) -> list[SearchResult]: +def search_serper(api_key: str, query: str, count: int, filter_list: Optional[list[str]] = None) -> list[SearchResult]: """Search using serper.dev's API and return the results as a list of SearchResult objects. Args: api_key (str): A serper.dev API key query (str): The query to search for """ - url = "https://google.serper.dev/search" + url = 'https://google.serper.dev/search' - payload = json.dumps({"q": query}) - headers = {"X-API-KEY": api_key, "Content-Type": "application/json"} + payload = json.dumps({'q': query}) + headers = {'X-API-KEY': api_key, 'Content-Type': 'application/json'} - response = requests.request("POST", url, headers=headers, data=payload) + response = requests.request('POST', url, headers=headers, data=payload) response.raise_for_status() json_response = response.json() - results = sorted( - json_response.get("organic", []), key=lambda x: x.get("position", 0) - ) + results = sorted(json_response.get('organic', []), key=lambda x: x.get('position', 0)) if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["link"], - title=result.get("title"), - snippet=result.get("description"), + link=result['link'], + title=result.get('title'), + snippet=result.get('snippet'), ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/serply.py b/backend/open_webui/retrieval/web/serply.py index 68843eba85..f245392b75 100644 --- a/backend/open_webui/retrieval/web/serply.py +++ b/backend/open_webui/retrieval/web/serply.py @@ -12,10 +12,10 @@ def search_serply( api_key: str, query: str, count: int, - hl: str = "us", + hl: str = 'us', limit: int = 10, - device_type: str = "desktop", - proxy_location: str = "US", + device_type: str = 'desktop', + proxy_location: str = 'US', filter_list: Optional[list[str]] = None, ) -> list[SearchResult]: """Search using serper.dev's API and return the results as a list of SearchResult objects. @@ -26,42 +26,40 @@ def search_serply( hl (str): Host Language code to display results in (reference https://developers.google.com/custom-search/docs/xml_results?hl=en#wsInterfaceLanguages) limit (int): The maximum number of results to return [10-100, defaults to 10] """ - log.info("Searching with Serply") + log.info('Searching with Serply') - url = "https://api.serply.io/v1/search/" + url = 'https://api.serply.io/v1/search/' query_payload = { - "q": query, - "language": "en", - "num": limit, - "gl": proxy_location.upper(), - "hl": hl.lower(), + 'q': query, + 'language': 'en', + 'num': limit, + 'gl': proxy_location.upper(), + 'hl': hl.lower(), } - url = f"{url}{urlencode(query_payload)}" + url = f'{url}{urlencode(query_payload)}' headers = { - "X-API-KEY": api_key, - "X-User-Agent": device_type, - "User-Agent": "open-webui", - "X-Proxy-Location": proxy_location, + 'X-API-KEY': api_key, + 'X-User-Agent': device_type, + 'User-Agent': 'open-webui', + 'X-Proxy-Location': proxy_location, } - response = requests.request("GET", url, headers=headers) + response = requests.request('GET', url, headers=headers) response.raise_for_status() json_response = response.json() - log.info(f"results from serply search: {json_response}") + log.info(f'results from serply search: {json_response}') - results = sorted( - json_response.get("results", []), key=lambda x: x.get("realPosition", 0) - ) + results = sorted(json_response.get('results', []), key=lambda x: x.get('realPosition', 0)) if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["link"], - title=result.get("title"), - snippet=result.get("description"), + link=result['link'], + title=result.get('title'), + snippet=result.get('description'), ) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/serpstack.py b/backend/open_webui/retrieval/web/serpstack.py index 97db858724..28a4956645 100644 --- a/backend/open_webui/retrieval/web/serpstack.py +++ b/backend/open_webui/retrieval/web/serpstack.py @@ -21,26 +21,22 @@ def search_serpstack( query (str): The query to search for https_enabled (bool): Whether to use HTTPS or HTTP for the API request """ - url = f"{'https' if https_enabled else 'http'}://api.serpstack.com/search" + url = f'{"https" if https_enabled else "http"}://api.serpstack.com/search' - headers = {"Content-Type": "application/json"} + headers = {'Content-Type': 'application/json'} params = { - "access_key": api_key, - "query": query, + 'access_key': api_key, + 'query': query, } - response = requests.request("POST", url, headers=headers, params=params) + response = requests.request('POST', url, headers=headers, params=params) response.raise_for_status() json_response = response.json() - results = sorted( - json_response.get("organic_results", []), key=lambda x: x.get("position", 0) - ) + results = sorted(json_response.get('organic_results', []), key=lambda x: x.get('position', 0)) if filter_list: results = get_filtered_results(results, filter_list) return [ - SearchResult( - link=result["url"], title=result.get("title"), snippet=result.get("snippet") - ) + SearchResult(link=result['url'], title=result.get('title'), snippet=result.get('snippet')) for result in results[:count] ] diff --git a/backend/open_webui/retrieval/web/sougou.py b/backend/open_webui/retrieval/web/sougou.py index d8747c3ade..b267374d79 100644 --- a/backend/open_webui/retrieval/web/sougou.py +++ b/backend/open_webui/retrieval/web/sougou.py @@ -26,33 +26,26 @@ def search_sougou( try: cred = credential.Credential(sougou_api_sid, sougou_api_sk) http_profile = HttpProfile() - http_profile.endpoint = "tms.tencentcloudapi.com" + http_profile.endpoint = 'tms.tencentcloudapi.com' client_profile = ClientProfile() client_profile.http_profile = http_profile - params = json.dumps({"Query": query, "Cnt": 20}) - common_client = CommonClient( - "tms", "2020-12-29", cred, "", profile=client_profile - ) + params = json.dumps({'Query': query, 'Cnt': 20}) + common_client = CommonClient('tms', '2020-12-29', cred, '', profile=client_profile) results = [ - json.loads(page) - for page in common_client.call_json("SearchPro", json.loads(params))[ - "Response" - ]["Pages"] + json.loads(page) for page in common_client.call_json('SearchPro', json.loads(params))['Response']['Pages'] ] - sorted_results = sorted( - results, key=lambda x: x.get("scour", 0.0), reverse=True - ) + sorted_results = sorted(results, key=lambda x: x.get('scour', 0.0), reverse=True) if filter_list: sorted_results = get_filtered_results(sorted_results, filter_list) return [ SearchResult( - link=result.get("url"), - title=result.get("title"), - snippet=result.get("passage"), + link=result.get('url'), + title=result.get('title'), + snippet=result.get('passage'), ) for result in sorted_results[:count] ] except TencentCloudSDKException as err: - log.error(f"Error in Sougou search: {err}") + log.error(f'Error in Sougou search: {err}') return [] diff --git a/backend/open_webui/retrieval/web/tavily.py b/backend/open_webui/retrieval/web/tavily.py index 6d9ff89a87..6b52bbb45b 100644 --- a/backend/open_webui/retrieval/web/tavily.py +++ b/backend/open_webui/retrieval/web/tavily.py @@ -24,26 +24,26 @@ def search_tavily( Returns: list[SearchResult]: A list of search results """ - url = "https://api.tavily.com/search" + url = 'https://api.tavily.com/search' headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {api_key}', } - data = {"query": query, "max_results": count} + data = {'query': query, 'max_results': count} response = requests.post(url, headers=headers, json=data) response.raise_for_status() json_response = response.json() - results = json_response.get("results", []) + results = json_response.get('results', []) if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["url"], - title=result.get("title", ""), - snippet=result.get("content"), + link=result['url'], + title=result.get('title', ''), + snippet=result.get('content'), ) for result in results ] diff --git a/backend/open_webui/retrieval/web/utils.py b/backend/open_webui/retrieval/web/utils.py index 45787cb4bd..c9442f208b 100644 --- a/backend/open_webui/retrieval/web/utils.py +++ b/backend/open_webui/retrieval/web/utils.py @@ -67,16 +67,14 @@ def validate_url(url: Union[str, Sequence[str]]): parsed_url = urllib.parse.urlparse(url) # Protocol validation - only allow http/https - if parsed_url.scheme not in ["http", "https"]: - log.warning( - f"Blocked non-HTTP(S) protocol: {parsed_url.scheme} in URL: {url}" - ) + if parsed_url.scheme not in ['http', 'https']: + log.warning(f'Blocked non-HTTP(S) protocol: {parsed_url.scheme} in URL: {url}') raise ValueError(ERROR_MESSAGES.INVALID_URL) # Blocklist check using unified filtering logic if WEB_FETCH_FILTER_LIST: if not is_string_allowed(url, WEB_FETCH_FILTER_LIST): - log.warning(f"URL blocked by filter list: {url}") + log.warning(f'URL blocked by filter list: {url}') raise ValueError(ERROR_MESSAGES.INVALID_URL) if not ENABLE_RAG_LOCAL_WEB_FETCH: @@ -106,29 +104,29 @@ def safe_validate_urls(url: Sequence[str]) -> Sequence[str]: if validate_url(u): valid_urls.append(u) except Exception as e: - log.debug(f"Invalid URL {u}: {str(e)}") + log.debug(f'Invalid URL {u}: {str(e)}') continue return valid_urls def extract_metadata(soup, url): - metadata = {"source": url} - 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.") + metadata = {'source': url} + 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.') return metadata def verify_ssl_cert(url: str) -> bool: """Verify SSL certificate for the given URL.""" - if not url.startswith("https://"): + if not url.startswith('https://'): return True try: - hostname = url.split("://")[-1].split("/")[0] + hostname = url.split('://')[-1].split('/')[0] context = ssl.create_default_context(cafile=certifi.where()) with context.wrap_socket(ssl.socket(), server_hostname=hostname) as s: s.connect((hostname, 443)) @@ -136,7 +134,7 @@ def verify_ssl_cert(url: str) -> bool: except ssl.SSLError: return False except Exception as e: - log.warning(f"SSL verification failed for {url}: {str(e)}") + log.warning(f'SSL verification failed for {url}: {str(e)}') return False @@ -168,14 +166,14 @@ async def _verify_ssl_cert(self, url: str) -> bool: async def _safe_process_url(self, url: str) -> bool: """Perform safety checks before processing a URL.""" if self.verify_ssl and not await self._verify_ssl_cert(url): - raise ValueError(f"SSL certificate verification failed for {url}") + raise ValueError(f'SSL certificate verification failed for {url}') await self._wait_for_rate_limit() return True def _safe_process_url_sync(self, url: str) -> bool: """Synchronous version of safety checks.""" if self.verify_ssl and not verify_ssl_cert(url): - raise ValueError(f"SSL certificate verification failed for {url}") + raise ValueError(f'SSL certificate verification failed for {url}') self._sync_wait_for_rate_limit() return True @@ -191,7 +189,7 @@ def __init__( api_key: Optional[str] = None, api_url: Optional[str] = None, timeout: Optional[int] = None, - mode: Literal["crawl", "scrape", "map"] = "scrape", + mode: Literal['crawl', 'scrape', 'map'] = 'scrape', proxy: Optional[Dict[str, str]] = None, params: Optional[Dict] = None, ): @@ -216,15 +214,15 @@ def __init__( params: The parameters to pass to the Firecrawl API. For more details, visit: https://docs.firecrawl.dev/sdks/python#batch-scrape """ - proxy_server = proxy.get("server") if proxy else None + proxy_server = proxy.get('server') if proxy else None if trust_env and not proxy_server: env_proxies = urllib.request.getproxies() - env_proxy_server = env_proxies.get("https") or env_proxies.get("http") + env_proxy_server = env_proxies.get('https') or env_proxies.get('http') if env_proxy_server: if proxy: - proxy["server"] = env_proxy_server + proxy['server'] = env_proxy_server else: - proxy = {"server": env_proxy_server} + proxy = {'server': env_proxy_server} self.web_paths = web_paths self.verify_ssl = verify_ssl self.requests_per_second = requests_per_second @@ -240,7 +238,7 @@ def __init__( def lazy_load(self) -> Iterator[Document]: """Load documents using FireCrawl batch_scrape.""" log.debug( - "Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s", + 'Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s', len(self.web_paths), self.mode, self.params, @@ -251,7 +249,7 @@ def lazy_load(self) -> Iterator[Document]: firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url) result = firecrawl.batch_scrape( self.web_paths, - formats=["markdown"], + formats=['markdown'], skip_tls_verification=not self.verify_ssl, ignore_invalid_urls=True, remove_base64_images=True, @@ -260,28 +258,26 @@ def lazy_load(self) -> Iterator[Document]: **self.params, ) - if result.status != "completed": - raise RuntimeError( - f"FireCrawl batch scrape did not complete successfully. result: {result}" - ) + if result.status != 'completed': + raise RuntimeError(f'FireCrawl batch scrape did not complete successfully. result: {result}') for data in result.data: metadata = data.metadata or {} yield Document( - page_content=data.markdown or "", - metadata={"source": metadata.url or metadata.source_url or ""}, + page_content=data.markdown or '', + metadata={'source': metadata.url or metadata.source_url or ''}, ) except Exception as e: if self.continue_on_failure: - log.exception(f"Error extracting content from URLs: {e}") + log.exception(f'Error extracting content from URLs: {e}') else: raise e async def alazy_load(self): """Async version of lazy_load.""" log.debug( - "Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s", + 'Starting FireCrawl batch scrape for %d URLs, mode: %s, params: %s', len(self.web_paths), self.mode, self.params, @@ -292,7 +288,7 @@ async def alazy_load(self): firecrawl = FirecrawlApp(api_key=self.api_key, api_url=self.api_url) result = firecrawl.batch_scrape( self.web_paths, - formats=["markdown"], + formats=['markdown'], skip_tls_verification=not self.verify_ssl, ignore_invalid_urls=True, remove_base64_images=True, @@ -301,21 +297,19 @@ async def alazy_load(self): **self.params, ) - if result.status != "completed": - raise RuntimeError( - f"FireCrawl batch scrape did not complete successfully. result: {result}" - ) + if result.status != 'completed': + raise RuntimeError(f'FireCrawl batch scrape did not complete successfully. result: {result}') for data in result.data: metadata = data.metadata or {} yield Document( - page_content=data.markdown or "", - metadata={"source": metadata.url or metadata.source_url or ""}, + page_content=data.markdown or '', + metadata={'source': metadata.url or metadata.source_url or ''}, ) except Exception as e: if self.continue_on_failure: - log.exception(f"Error extracting content from URLs: {e}") + log.exception(f'Error extracting content from URLs: {e}') else: raise e @@ -325,7 +319,7 @@ def __init__( self, web_paths: Union[str, List[str]], api_key: str, - extract_depth: Literal["basic", "advanced"] = "basic", + extract_depth: Literal['basic', 'advanced'] = 'basic', continue_on_failure: bool = True, requests_per_second: Optional[float] = None, verify_ssl: bool = True, @@ -345,15 +339,15 @@ def __init__( proxy: Optional proxy configuration. """ # Initialize proxy configuration if using environment variables - proxy_server = proxy.get("server") if proxy else None + proxy_server = proxy.get('server') if proxy else None if trust_env and not proxy_server: env_proxies = urllib.request.getproxies() - env_proxy_server = env_proxies.get("https") or env_proxies.get("http") + env_proxy_server = env_proxies.get('https') or env_proxies.get('http') if env_proxy_server: if proxy: - proxy["server"] = env_proxy_server + proxy['server'] = env_proxy_server else: - proxy = {"server": env_proxy_server} + proxy = {'server': env_proxy_server} # Store parameters for creating TavilyLoader instances self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths] @@ -376,14 +370,14 @@ def lazy_load(self) -> Iterator[Document]: self._safe_process_url_sync(url) valid_urls.append(url) except Exception as e: - log.warning(f"SSL verification failed for {url}: {str(e)}") + log.warning(f'SSL verification failed for {url}: {str(e)}') if not self.continue_on_failure: raise e if not valid_urls: if self.continue_on_failure: - log.warning("No valid URLs to process after SSL verification") + log.warning('No valid URLs to process after SSL verification') return - raise ValueError("No valid URLs to process after SSL verification") + raise ValueError('No valid URLs to process after SSL verification') try: loader = TavilyLoader( urls=valid_urls, @@ -394,7 +388,7 @@ def lazy_load(self) -> Iterator[Document]: yield from loader.lazy_load() except Exception as e: if self.continue_on_failure: - log.exception(f"Error extracting content from URLs: {e}") + log.exception(f'Error extracting content from URLs: {e}') else: raise e @@ -406,15 +400,15 @@ async def alazy_load(self) -> AsyncIterator[Document]: await self._safe_process_url(url) valid_urls.append(url) except Exception as e: - log.warning(f"SSL verification failed for {url}: {str(e)}") + log.warning(f'SSL verification failed for {url}: {str(e)}') if not self.continue_on_failure: raise e if not valid_urls: if self.continue_on_failure: - log.warning("No valid URLs to process after SSL verification") + log.warning('No valid URLs to process after SSL verification') return - raise ValueError("No valid URLs to process after SSL verification") + raise ValueError('No valid URLs to process after SSL verification') try: loader = TavilyLoader( @@ -427,7 +421,7 @@ async def alazy_load(self) -> AsyncIterator[Document]: yield document except Exception as e: if self.continue_on_failure: - log.exception(f"Error loading URLs: {e}") + log.exception(f'Error loading URLs: {e}') else: raise e @@ -462,15 +456,15 @@ def __init__( ): """Initialize with additional safety parameters and remote browser support.""" - proxy_server = proxy.get("server") if proxy else None + proxy_server = proxy.get('server') if proxy else None if trust_env and not proxy_server: env_proxies = urllib.request.getproxies() - env_proxy_server = env_proxies.get("https") or env_proxies.get("http") + env_proxy_server = env_proxies.get('https') or env_proxies.get('http') if env_proxy_server: if proxy: - proxy["server"] = env_proxy_server + proxy['server'] = env_proxy_server else: - proxy = {"server": env_proxy_server} + proxy = {'server': env_proxy_server} # We'll set headless to False if using playwright_ws_url since it's handled by the remote browser super().__init__( @@ -504,14 +498,14 @@ def lazy_load(self) -> Iterator[Document]: page = browser.new_page() response = page.goto(url, timeout=self.playwright_timeout) if response is None: - raise ValueError(f"page.goto() returned None for url {url}") + raise ValueError(f'page.goto() returned None for url {url}') text = self.evaluator.evaluate(page, browser, response) - metadata = {"source": url} + 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}") + log.exception(f'Error loading {url}: {e}') continue raise e browser.close() @@ -525,9 +519,7 @@ async def alazy_load(self) -> AsyncIterator[Document]: if self.playwright_ws_url: browser = await p.chromium.connect(self.playwright_ws_url) else: - browser = await p.chromium.launch( - headless=self.headless, proxy=self.proxy - ) + browser = await p.chromium.launch(headless=self.headless, proxy=self.proxy) for url in self.urls: try: @@ -535,14 +527,14 @@ async def alazy_load(self) -> AsyncIterator[Document]: page = await browser.new_page() response = await page.goto(url, timeout=self.playwright_timeout) if response is None: - raise ValueError(f"page.goto() returned None for url {url}") + raise ValueError(f'page.goto() returned None for url {url}') text = await self.evaluator.evaluate_async(page, browser, response) - metadata = {"source": url} + 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}") + log.exception(f'Error loading {url}: {e}') continue raise e await browser.close() @@ -560,9 +552,7 @@ def __init__(self, trust_env: bool = False, *args, **kwargs): super().__init__(*args, **kwargs) self.trust_env = trust_env - async def _fetch( - self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5 - ) -> str: + async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str: async with aiohttp.ClientSession(trust_env=self.trust_env) as session: for i in range(retries): try: @@ -571,7 +561,7 @@ async def _fetch( cookies=self.session.cookies.get_dict(), ) if not self.session.verify: - kwargs["ssl"] = False + kwargs['ssl'] = False async with session.get( url, @@ -585,16 +575,11 @@ async def _fetch( if i == retries - 1: raise else: - log.warning( - f"Error fetching {url} with attempt " - f"{i + 1}/{retries}: {e}. Retrying..." - ) + log.warning(f'Error fetching {url} with attempt {i + 1}/{retries}: {e}. Retrying...') await asyncio.sleep(cooldown * backoff**i) - raise ValueError("retry count exceeded") + raise ValueError('retry count exceeded') - def _unpack_fetch_results( - self, results: Any, urls: List[str], parser: Union[str, None] = None - ) -> List[Any]: + def _unpack_fetch_results(self, results: Any, urls: List[str], parser: Union[str, None] = None) -> List[Any]: """Unpack fetch results into BeautifulSoup objects.""" from bs4 import BeautifulSoup @@ -602,17 +587,15 @@ def _unpack_fetch_results( for i, result in enumerate(results): url = urls[i] if parser is None: - if url.endswith(".xml"): - parser = "xml" + if url.endswith('.xml'): + parser = 'xml' else: parser = self.default_parser self._check_parser(parser) final_results.append(BeautifulSoup(result, parser, **self.bs_kwargs)) return final_results - async def ascrape_all( - self, urls: List[str], parser: Union[str, None] = None - ) -> List[Any]: + 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) @@ -630,22 +613,20 @@ def lazy_load(self) -> Iterator[Document]: yield Document(page_content=text, metadata=metadata) except Exception as e: # Log the error and continue with the next URL - log.exception(f"Error loading {path}: {e}") + log.exception(f'Error loading {path}: {e}') 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.") + 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) async def aload(self) -> list[Document]: @@ -663,18 +644,18 @@ def get_web_loader( safe_urls = safe_validate_urls([urls] if isinstance(urls, str) else urls) if not safe_urls: - log.warning(f"All provided URLs were blocked or invalid: {urls}") + log.warning(f'All provided URLs were blocked or invalid: {urls}') raise ValueError(ERROR_MESSAGES.INVALID_URL) web_loader_args = { - "web_paths": safe_urls, - "verify_ssl": verify_ssl, - "requests_per_second": requests_per_second, - "continue_on_failure": True, - "trust_env": trust_env, + 'web_paths': safe_urls, + 'verify_ssl': verify_ssl, + 'requests_per_second': requests_per_second, + 'continue_on_failure': True, + 'trust_env': trust_env, } - if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web": + if WEB_LOADER_ENGINE.value == '' or WEB_LOADER_ENGINE.value == 'safe_web': WebLoaderClass = SafeWebBaseLoader request_kwargs = {} @@ -685,42 +666,42 @@ def get_web_loader( timeout_value = None if timeout_value: - request_kwargs["timeout"] = timeout_value + request_kwargs['timeout'] = timeout_value if request_kwargs: - web_loader_args["requests_kwargs"] = request_kwargs + web_loader_args['requests_kwargs'] = request_kwargs - if WEB_LOADER_ENGINE.value == "playwright": + if WEB_LOADER_ENGINE.value == 'playwright': WebLoaderClass = SafePlaywrightURLLoader - web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value + web_loader_args['playwright_timeout'] = PLAYWRIGHT_TIMEOUT.value if PLAYWRIGHT_WS_URL.value: - web_loader_args["playwright_ws_url"] = PLAYWRIGHT_WS_URL.value + web_loader_args['playwright_ws_url'] = PLAYWRIGHT_WS_URL.value - if WEB_LOADER_ENGINE.value == "firecrawl": + if WEB_LOADER_ENGINE.value == 'firecrawl': WebLoaderClass = SafeFireCrawlLoader - web_loader_args["api_key"] = FIRECRAWL_API_KEY.value - web_loader_args["api_url"] = FIRECRAWL_API_BASE_URL.value + web_loader_args['api_key'] = FIRECRAWL_API_KEY.value + web_loader_args['api_url'] = FIRECRAWL_API_BASE_URL.value if FIRECRAWL_TIMEOUT.value: try: - web_loader_args["timeout"] = int(FIRECRAWL_TIMEOUT.value) + web_loader_args['timeout'] = int(FIRECRAWL_TIMEOUT.value) except ValueError: pass - if WEB_LOADER_ENGINE.value == "tavily": + if WEB_LOADER_ENGINE.value == 'tavily': WebLoaderClass = SafeTavilyLoader - web_loader_args["api_key"] = TAVILY_API_KEY.value - web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value + web_loader_args['api_key'] = TAVILY_API_KEY.value + web_loader_args['extract_depth'] = TAVILY_EXTRACT_DEPTH.value - if WEB_LOADER_ENGINE.value == "external": + if WEB_LOADER_ENGINE.value == 'external': WebLoaderClass = ExternalWebLoader - web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value - web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value + web_loader_args['external_url'] = EXTERNAL_WEB_LOADER_URL.value + web_loader_args['external_api_key'] = EXTERNAL_WEB_LOADER_API_KEY.value if WebLoaderClass: web_loader = WebLoaderClass(**web_loader_args) log.debug( - "Using WEB_LOADER_ENGINE %s for %s URLs", + 'Using WEB_LOADER_ENGINE %s for %s URLs', web_loader.__class__.__name__, len(safe_urls), ) @@ -728,6 +709,6 @@ def get_web_loader( return web_loader else: raise ValueError( - f"Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE.value}. " + f'Invalid WEB_LOADER_ENGINE: {WEB_LOADER_ENGINE.value}. ' "Please set it to 'safe_web', 'playwright', 'firecrawl', or 'tavily'." ) diff --git a/backend/open_webui/retrieval/web/yacy.py b/backend/open_webui/retrieval/web/yacy.py index 2419717b24..32ca04f531 100644 --- a/backend/open_webui/retrieval/web/yacy.py +++ b/backend/open_webui/retrieval/web/yacy.py @@ -41,29 +41,29 @@ def search_yacy( yacy_auth = HTTPDigestAuth(username, password) params = { - "query": query, - "contentdom": "text", - "resource": "global", - "maximumRecords": count, - "nav": "none", + 'query': query, + 'contentdom': 'text', + 'resource': 'global', + 'maximumRecords': count, + 'nav': 'none', } # Check if provided a json API URL - if not query_url.endswith("yacysearch.json"): + if not query_url.endswith('yacysearch.json'): # Strip all query parameters from the URL - query_url = query_url.rstrip("/") + "/yacysearch.json" + query_url = query_url.rstrip('/') + '/yacysearch.json' - log.debug(f"searching {query_url}") + log.debug(f'searching {query_url}') response = requests.get( query_url, auth=yacy_auth, headers={ - "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", - "Accept": "text/html", - "Accept-Encoding": "gzip, deflate", - "Accept-Language": "en-US,en;q=0.5", - "Connection": "keep-alive", + 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot', + 'Accept': 'text/html', + 'Accept-Encoding': 'gzip, deflate', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', }, params=params, ) @@ -71,15 +71,15 @@ def search_yacy( response.raise_for_status() # Raise an exception for HTTP errors. json_response = response.json() - results = json_response.get("channels", [{}])[0].get("items", []) - sorted_results = sorted(results, key=lambda x: x.get("ranking", 0), reverse=True) + results = json_response.get('channels', [{}])[0].get('items', []) + sorted_results = sorted(results, key=lambda x: x.get('ranking', 0), reverse=True) if filter_list: sorted_results = get_filtered_results(sorted_results, filter_list) return [ SearchResult( - link=result["link"], - title=result.get("title"), - snippet=result.get("description"), + link=result['link'], + title=result.get('title'), + snippet=result.get('description'), ) for result in sorted_results[:count] ] diff --git a/backend/open_webui/retrieval/web/yandex.py b/backend/open_webui/retrieval/web/yandex.py index fba4ee482e..352d2a3afb 100644 --- a/backend/open_webui/retrieval/web/yandex.py +++ b/backend/open_webui/retrieval/web/yandex.py @@ -20,14 +20,14 @@ def xml_element_contents_to_string(element: Element) -> str: - buffer = [element.text if element.text else ""] + buffer = [element.text if element.text else ''] for child in element: buffer.append(xml_element_contents_to_string(child)) - buffer.append(element.tail if element.tail else "") + buffer.append(element.tail if element.tail else '') - return "".join(buffer) + return ''.join(buffer) def search_yandex( @@ -42,42 +42,38 @@ def search_yandex( ) -> List[SearchResult]: try: headers = { - "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", - "Authorization": f"Api-Key {yandex_search_api_key}", + 'User-Agent': 'Open WebUI (https://github.com/open-webui/open-webui) RAG Bot', + 'Authorization': f'Api-Key {yandex_search_api_key}', } if user is not None: headers = include_user_info_headers(headers, user) - chat_id = getattr(request.state, "chat_id", None) + chat_id = getattr(request.state, 'chat_id', None) if chat_id: headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = str(chat_id) - payload = {} if yandex_search_config == "" else json.loads(yandex_search_config) + payload = {} if yandex_search_config == '' else json.loads(yandex_search_config) - if type(payload.get("query", None)) != dict: - payload["query"] = {} + if type(payload.get('query', None)) != dict: + payload['query'] = {} - if "searchType" not in payload["query"]: - payload["query"]["searchType"] = "SEARCH_TYPE_RU" + if 'searchType' not in payload['query']: + payload['query']['searchType'] = 'SEARCH_TYPE_RU' - payload["query"]["queryText"] = query + payload['query']['queryText'] = query - if type(payload.get("groupSpec", None)) != dict: - payload["groupSpec"] = {} + if type(payload.get('groupSpec', None)) != dict: + payload['groupSpec'] = {} - if "groupMode" not in payload["groupSpec"]: - payload["groupSpec"]["groupMode"] = "GROUP_MODE_DEEP" + if 'groupMode' not in payload['groupSpec']: + payload['groupSpec']['groupMode'] = 'GROUP_MODE_DEEP' - payload["groupSpec"]["groupsOnPage"] = count - payload["groupSpec"]["docsInGroup"] = 1 + payload['groupSpec']['groupsOnPage'] = count + payload['groupSpec']['docsInGroup'] = 1 response = requests.post( - ( - "https://searchapi.api.cloud.yandex.net/v2/web/search" - if yandex_search_url == "" - else yandex_search_url - ), + ('https://searchapi.api.cloud.yandex.net/v2/web/search' if yandex_search_url == '' else yandex_search_url), headers=headers, json=payload, ) @@ -85,29 +81,21 @@ def search_yandex( response.raise_for_status() response_body = response.json() - if "rawData" not in response_body: - raise Exception(f"No `rawData` in response body: {response_body}") + if 'rawData' not in response_body: + raise Exception(f'No `rawData` in response body: {response_body}') - search_result_body_bytes = base64.decodebytes( - bytes(response_body["rawData"], "utf-8") - ) + search_result_body_bytes = base64.decodebytes(bytes(response_body['rawData'], 'utf-8')) doc_root = ET.parse(io.BytesIO(search_result_body_bytes)) results = [] - for group in doc_root.findall("response/results/grouping/group"): + for group in doc_root.findall('response/results/grouping/group'): results.append( { - "url": xml_element_contents_to_string(group.find("doc/url")).strip( - "\n" - ), - "title": xml_element_contents_to_string( - group.find("doc/title") - ).strip("\n"), - "snippet": xml_element_contents_to_string( - group.find("doc/passages/passage") - ), + 'url': xml_element_contents_to_string(group.find('doc/url')).strip('\n'), + 'title': xml_element_contents_to_string(group.find('doc/title')).strip('\n'), + 'snippet': xml_element_contents_to_string(group.find('doc/passages/passage')), } ) @@ -115,49 +103,47 @@ def search_yandex( results = [ SearchResult( - link=result.get("url"), - title=result.get("title"), - snippet=result.get("snippet"), + link=result.get('url'), + title=result.get('title'), + snippet=result.get('snippet'), ) for result in results[:count] ] - log.info(f"Yandex search results: {results}") + log.info(f'Yandex search results: {results}') return results except Exception as e: - log.error(f"Error in search: {e}") + log.error(f'Error in search: {e}') return [] -if __name__ == "__main__": +if __name__ == '__main__': from starlette.datastructures import Headers from fastapi import FastAPI result = search_yandex( Request( { - "type": "http", - "asgi.version": "3.0", - "asgi.spec_version": "2.0", - "method": "GET", - "path": "/internal", - "query_string": b"", - "headers": Headers({}).raw, - "client": ("127.0.0.1", 12345), - "server": ("127.0.0.1", 80), - "scheme": "http", - "app": FastAPI(), + 'type': 'http', + 'asgi.version': '3.0', + 'asgi.spec_version': '2.0', + 'method': 'GET', + 'path': '/internal', + 'query_string': b'', + 'headers': Headers({}).raw, + 'client': ('127.0.0.1', 12345), + 'server': ('127.0.0.1', 80), + 'scheme': 'http', + 'app': FastAPI(), }, None, ), - os.environ.get("YANDEX_WEB_SEARCH_URL", ""), - os.environ.get("YANDEX_WEB_SEARCH_API_KEY", ""), - os.environ.get( - "YANDEX_WEB_SEARCH_CONFIG", '{"query": {"searchType": "SEARCH_TYPE_COM"}}' - ), - "TOP movies of the past year", + os.environ.get('YANDEX_WEB_SEARCH_URL', ''), + os.environ.get('YANDEX_WEB_SEARCH_API_KEY', ''), + os.environ.get('YANDEX_WEB_SEARCH_CONFIG', '{"query": {"searchType": "SEARCH_TYPE_COM"}}'), + 'TOP movies of the past year', 3, ) diff --git a/backend/open_webui/retrieval/web/ydc.py b/backend/open_webui/retrieval/web/ydc.py index 21d725a895..21059d8b03 100644 --- a/backend/open_webui/retrieval/web/ydc.py +++ b/backend/open_webui/retrieval/web/ydc.py @@ -12,7 +12,7 @@ def search_youcom( query: str, count: int, filter_list: Optional[List[str]] = None, - language: str = "EN", + language: str = 'EN', ) -> List[SearchResult]: """Search using You.com's YDC Index API and return the results as a list of SearchResult objects. @@ -23,30 +23,30 @@ def search_youcom( filter_list (list[str], optional): Domain filter list language (str): Language code for search results (default: "EN") """ - url = "https://ydc-index.io/v1/search" + url = 'https://ydc-index.io/v1/search' headers = { - "Accept": "application/json", - "X-API-KEY": api_key, + 'Accept': 'application/json', + 'X-API-KEY': api_key, } params = { - "query": query, - "count": count, - "language": language, + 'query': query, + 'count': count, + 'language': language, } response = requests.get(url, headers=headers, params=params) response.raise_for_status() json_response = response.json() - results = json_response.get("results", {}).get("web", []) + results = json_response.get('results', {}).get('web', []) if filter_list: results = get_filtered_results(results, filter_list) return [ SearchResult( - link=result["url"], - title=result.get("title"), + link=result['url'], + title=result.get('title'), snippet=_build_snippet(result), ) for result in results[:count] @@ -62,12 +62,12 @@ def _build_snippet(result: dict) -> str: """ parts: list[str] = [] - description = result.get("description") + description = result.get('description') if description: parts.append(description) - snippets = result.get("snippets") + snippets = result.get('snippets') if snippets and isinstance(snippets, list): parts.extend(snippets) - return "\n\n".join(parts) + return '\n\n'.join(parts) diff --git a/backend/open_webui/routers/analytics.py b/backend/open_webui/routers/analytics.py index 9579845a49..790c134295 100644 --- a/backend/open_webui/routers/analytics.py +++ b/backend/open_webui/routers/analytics.py @@ -53,18 +53,16 @@ class UserAnalyticsResponse(BaseModel): #################### -@router.get("/models", response_model=ModelAnalyticsResponse) +@router.get('/models', response_model=ModelAnalyticsResponse) async def get_model_analytics( - start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), - end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), - group_id: Optional[str] = Query(None, description="Filter by user group ID"), + start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'), + end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), + group_id: Optional[str] = Query(None, description='Filter by user group ID'), user=Depends(get_admin_user), db: Session = Depends(get_session), ): """Get message counts per model.""" - counts = ChatMessages.get_message_count_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) + counts = ChatMessages.get_message_count_by_model(start_date=start_date, end_date=end_date, group_id=group_id, db=db) models = [ ModelAnalyticsEntry(model_id=model_id, count=count) for model_id, count in sorted(counts.items(), key=lambda x: -x[1]) @@ -72,27 +70,23 @@ async def get_model_analytics( return ModelAnalyticsResponse(models=models) -@router.get("/users", response_model=UserAnalyticsResponse) +@router.get('/users', response_model=UserAnalyticsResponse) async def get_user_analytics( - start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), - end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), - group_id: Optional[str] = Query(None, description="Filter by user group ID"), - limit: int = Query(50, description="Max users to return"), + start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'), + end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), + group_id: Optional[str] = Query(None, description='Filter by user group ID'), + limit: int = Query(50, description='Max users to return'), user=Depends(get_admin_user), db: Session = Depends(get_session), ): """Get message counts and token usage per user with user info.""" - counts = ChatMessages.get_message_count_by_user( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) + counts = ChatMessages.get_message_count_by_user(start_date=start_date, end_date=end_date, group_id=group_id, db=db) token_usage = ChatMessages.get_token_usage_by_user( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) # Get user info for top users - top_user_ids = [ - uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit] - ] + top_user_ids = [uid for uid, _ in sorted(counts.items(), key=lambda x: -x[1])[:limit]] user_info = {u.id: u for u in Users.get_users_by_user_ids(top_user_ids, db=db)} users = [] @@ -105,22 +99,22 @@ async def get_user_analytics( name=u.name if u else None, email=u.email if u else None, count=counts[user_id], - input_tokens=tokens.get("input_tokens", 0), - output_tokens=tokens.get("output_tokens", 0), - total_tokens=tokens.get("total_tokens", 0), + input_tokens=tokens.get('input_tokens', 0), + output_tokens=tokens.get('output_tokens', 0), + total_tokens=tokens.get('total_tokens', 0), ) ) return UserAnalyticsResponse(users=users) -@router.get("/messages", response_model=list[ChatMessageModel]) +@router.get('/messages', response_model=list[ChatMessageModel]) async def get_messages( - model_id: Optional[str] = Query(None, description="Filter by model ID"), - user_id: Optional[str] = Query(None, description="Filter by user ID"), - chat_id: Optional[str] = Query(None, description="Filter by chat ID"), - start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), - end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), + model_id: Optional[str] = Query(None, description='Filter by model ID'), + user_id: Optional[str] = Query(None, description='Filter by user ID'), + chat_id: Optional[str] = Query(None, description='Filter by chat ID'), + start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'), + end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), skip: int = Query(0), limit: int = Query(50, le=100), user=Depends(get_admin_user), @@ -139,9 +133,7 @@ async def get_messages( db=db, ) elif user_id: - return ChatMessages.get_messages_by_user_id( - user_id=user_id, skip=skip, limit=limit, db=db - ) + return ChatMessages.get_messages_by_user_id(user_id=user_id, skip=skip, limit=limit, db=db) else: # Return empty if no filter specified return [] @@ -154,11 +146,11 @@ class SummaryResponse(BaseModel): total_users: int -@router.get("/summary", response_model=SummaryResponse) +@router.get('/summary', response_model=SummaryResponse) async def get_summary( - start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), - end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), - group_id: Optional[str] = Query(None, description="Filter by user group ID"), + start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'), + end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), + group_id: Optional[str] = Query(None, description='Filter by user group ID'), user=Depends(get_admin_user), db: Session = Depends(get_session), ): @@ -190,29 +182,24 @@ class DailyStatsResponse(BaseModel): data: list[DailyStatsEntry] -@router.get("/daily", response_model=DailyStatsResponse) +@router.get('/daily', response_model=DailyStatsResponse) async def get_daily_stats( - start_date: Optional[int] = Query(None, description="Start timestamp (epoch)"), - end_date: Optional[int] = Query(None, description="End timestamp (epoch)"), - group_id: Optional[str] = Query(None, description="Filter by user group ID"), - granularity: str = Query("daily", description="Granularity: 'hourly' or 'daily'"), + start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'), + end_date: Optional[int] = Query(None, description='End timestamp (epoch)'), + group_id: Optional[str] = Query(None, description='Filter by user group ID'), + granularity: str = Query('daily', description="Granularity: 'hourly' or 'daily'"), user=Depends(get_admin_user), db: Session = Depends(get_session), ): """Get message counts grouped by model for time-series chart.""" - if granularity == "hourly": - counts = ChatMessages.get_hourly_message_counts_by_model( - start_date=start_date, end_date=end_date, db=db - ) + if granularity == 'hourly': + counts = ChatMessages.get_hourly_message_counts_by_model(start_date=start_date, end_date=end_date, db=db) else: counts = ChatMessages.get_daily_message_counts_by_model( start_date=start_date, end_date=end_date, group_id=group_id, db=db ) return DailyStatsResponse( - data=[ - DailyStatsEntry(date=date, models=models) - for date, models in sorted(counts.items()) - ] + data=[DailyStatsEntry(date=date, models=models) for date, models in sorted(counts.items())] ) @@ -231,22 +218,20 @@ class TokenUsageResponse(BaseModel): total_tokens: int -@router.get("/tokens", response_model=TokenUsageResponse) +@router.get('/tokens', response_model=TokenUsageResponse) async def get_token_usage( start_date: Optional[int] = Query(None), end_date: Optional[int] = Query(None), - group_id: Optional[str] = Query(None, description="Filter by user group ID"), + group_id: Optional[str] = Query(None, description='Filter by user group ID'), user=Depends(get_admin_user), db: Session = Depends(get_session), ): """Get token usage aggregated by model.""" - usage = ChatMessages.get_token_usage_by_model( - start_date=start_date, end_date=end_date, group_id=group_id, db=db - ) + usage = ChatMessages.get_token_usage_by_model(start_date=start_date, end_date=end_date, group_id=group_id, db=db) models = [ TokenUsageEntry(model_id=model_id, **data) - for model_id, data in sorted(usage.items(), key=lambda x: -x[1]["total_tokens"]) + for model_id, data in sorted(usage.items(), key=lambda x: -x[1]['total_tokens']) ] total_input = sum(m.input_tokens for m in models) @@ -278,7 +263,7 @@ class ModelChatsResponse(BaseModel): total: int -@router.get("/models/{model_id:path}/chats", response_model=ModelChatsResponse) +@router.get('/models/{model_id:path}/chats', response_model=ModelChatsResponse) async def get_model_chats( model_id: str, start_date: Optional[int] = Query(None), @@ -311,7 +296,7 @@ async def get_model_chats( continue # Get user_id from first user message - first_user_msg = next((m for m in messages if m.role == "user"), None) + first_user_msg = next((m for m in messages if m.role == 'user'), None) user_id = first_user_msg.user_id if first_user_msg else None # Extract first message content as preview @@ -321,8 +306,8 @@ async def get_model_chats( if isinstance(content, str): first_message = content[:200] elif isinstance(content, list): - text_parts = [b.get("text", "") for b in content if isinstance(b, dict)] - first_message = " ".join(text_parts)[:200] + text_parts = [b.get('text', '') for b in content if isinstance(b, dict)] + first_message = ' '.join(text_parts)[:200] # Get user info user_name = None @@ -367,10 +352,10 @@ class ModelOverviewResponse(BaseModel): tags: list[TagEntry] -@router.get("/models/{model_id:path}/overview", response_model=ModelOverviewResponse) +@router.get('/models/{model_id:path}/overview', response_model=ModelOverviewResponse) async def get_model_overview( model_id: str, - days: int = Query(30, description="Number of days of history (0 for all)"), + days: int = Query(30, description='Number of days of history (0 for all)'), user=Depends(get_admin_user), db: Session = Depends(get_session), ): @@ -387,7 +372,7 @@ async def get_model_overview( ) # Get feedback history per day - history_counts: dict[str, dict] = defaultdict(lambda: {"won": 0, "lost": 0}) + history_counts: dict[str, dict] = defaultdict(lambda: {'won': 0, 'lost': 0}) # Calculate start date for history now = datetime.now() @@ -398,19 +383,19 @@ async def get_model_overview( for chat_id in chat_ids: feedbacks = Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db) for fb in feedbacks: - if fb.data and "rating" in fb.data: - rating = fb.data["rating"] + if fb.data and 'rating' in fb.data: + rating = fb.data['rating'] fb_date = datetime.fromtimestamp(fb.created_at) # Filter by date range if start_dt and fb_date < start_dt: continue - date_str = fb_date.strftime("%Y-%m-%d") + date_str = fb_date.strftime('%Y-%m-%d') if rating == 1: - history_counts[date_str]["won"] += 1 + history_counts[date_str]['won'] += 1 elif rating == -1: - history_counts[date_str]["lost"] += 1 + history_counts[date_str]['lost'] += 1 # Fill in missing days history = [] @@ -421,18 +406,18 @@ async def get_model_overview( elif history_counts: # Find earliest date min_date = min(history_counts.keys()) - current = datetime.strptime(min_date, "%Y-%m-%d") + current = datetime.strptime(min_date, '%Y-%m-%d') else: current = now while current <= end_dt: - date_str = current.strftime("%Y-%m-%d") - counts = history_counts.get(date_str, {"won": 0, "lost": 0}) + date_str = current.strftime('%Y-%m-%d') + counts = history_counts.get(date_str, {'won': 0, 'lost': 0}) history.append( HistoryEntry( date=date_str, - won=counts["won"], - lost=counts["lost"], + won=counts['won'], + lost=counts['lost'], ) ) current += timedelta(days=1) @@ -442,13 +427,10 @@ async def get_model_overview( for chat_id in chat_ids: chat = Chats.get_chat_by_id(chat_id, db=db) if chat and chat.meta: - for tag in chat.meta.get("tags", []): + for tag in chat.meta.get('tags', []): tag_counts[tag] += 1 # Sort by count and take top 10 - tags = [ - TagEntry(tag=tag, count=count) - for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10] - ] + tags = [TagEntry(tag=tag, count=count) for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]] return ModelOverviewResponse(history=history, tags=tags) diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index a1f3ac523f..8e14387a78 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -68,13 +68,15 @@ log = logging.getLogger(__name__) -SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" +SPEECH_CACHE_DIR = CACHE_DIR / 'audio' / 'speech' SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) ########################################## # # Utility functions +# Let what is spoken here be heard clearly, and let +# no voice be reduced to noise along the way. # ########################################## @@ -86,19 +88,19 @@ def is_audio_conversion_required(file_path): """ Check if the given audio file needs conversion to mp3. """ - SUPPORTED_FORMATS = {"flac", "m4a", "mp3", "mp4", "mpeg", "wav", "webm"} + SUPPORTED_FORMATS = {'flac', 'm4a', 'mp3', 'mp4', 'mpeg', 'wav', 'webm'} if not os.path.isfile(file_path): - log.error(f"File not found: {file_path}") + log.error(f'File not found: {file_path}') return False try: info = mediainfo(file_path) - codec_name = info.get("codec_name", "").lower() - codec_type = info.get("codec_type", "").lower() - codec_tag_string = info.get("codec_tag_string", "").lower() + codec_name = info.get('codec_name', '').lower() + codec_type = info.get('codec_type', '').lower() + codec_tag_string = info.get('codec_tag_string', '').lower() - if codec_name == "aac" and codec_type == "audio" and codec_tag_string == "mp4a": + if codec_name == 'aac' and codec_type == 'audio' and codec_tag_string == 'mp4a': # File is AAC/mp4a audio, recommend mp3 conversion return True @@ -108,20 +110,20 @@ def is_audio_conversion_required(file_path): return True except Exception as e: - log.error(f"Error getting audio format: {e}") + log.error(f'Error getting audio format: {e}') return False def convert_audio_to_mp3(file_path): """Convert audio file to mp3 format.""" try: - output_path = os.path.splitext(file_path)[0] + ".mp3" + output_path = os.path.splitext(file_path)[0] + '.mp3' audio = AudioSegment.from_file(file_path) - audio.export(output_path, format="mp3") - log.info(f"Converted {file_path} to {output_path}") + audio.export(output_path, format='mp3') + log.info(f'Converted {file_path} to {output_path}') return output_path except Exception as e: - log.error(f"Error converting audio file: {e}") + log.error(f'Error converting audio file: {e}') return None @@ -131,20 +133,18 @@ def set_faster_whisper_model(model: str, auto_update: bool = False): from faster_whisper import WhisperModel faster_whisper_kwargs = { - "model_size_or_path": model, - "device": DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu", - "compute_type": WHISPER_COMPUTE_TYPE, - "download_root": WHISPER_MODEL_DIR, - "local_files_only": not auto_update, + 'model_size_or_path': model, + 'device': DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == 'cuda' else 'cpu', + 'compute_type': WHISPER_COMPUTE_TYPE, + 'download_root': WHISPER_MODEL_DIR, + 'local_files_only': not auto_update, } try: whisper_model = WhisperModel(**faster_whisper_kwargs) except Exception: - log.warning( - "WhisperModel initialization failed, attempting download with local_files_only=False" - ) - faster_whisper_kwargs["local_files_only"] = False + log.warning('WhisperModel initialization failed, attempting download with local_files_only=False') + faster_whisper_kwargs['local_files_only'] = False whisper_model = WhisperModel(**faster_whisper_kwargs) return whisper_model @@ -193,46 +193,44 @@ class AudioConfigUpdateForm(BaseModel): stt: STTConfigForm -@router.get("/config") +@router.get('/config') async def get_audio_config(request: Request, user=Depends(get_admin_user)): return { - "tts": { - "OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL, - "OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY, - "OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS, - "API_KEY": request.app.state.config.TTS_API_KEY, - "ENGINE": request.app.state.config.TTS_ENGINE, - "MODEL": request.app.state.config.TTS_MODEL, - "VOICE": request.app.state.config.TTS_VOICE, - "SPLIT_ON": request.app.state.config.TTS_SPLIT_ON, - "AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION, - "AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, - "AZURE_SPEECH_OUTPUT_FORMAT": request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, + 'tts': { + 'OPENAI_API_BASE_URL': request.app.state.config.TTS_OPENAI_API_BASE_URL, + 'OPENAI_API_KEY': request.app.state.config.TTS_OPENAI_API_KEY, + 'OPENAI_PARAMS': request.app.state.config.TTS_OPENAI_PARAMS, + 'API_KEY': request.app.state.config.TTS_API_KEY, + 'ENGINE': request.app.state.config.TTS_ENGINE, + 'MODEL': request.app.state.config.TTS_MODEL, + 'VOICE': request.app.state.config.TTS_VOICE, + 'SPLIT_ON': request.app.state.config.TTS_SPLIT_ON, + 'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION, + 'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, + 'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, }, - "stt": { - "OPENAI_API_BASE_URL": request.app.state.config.STT_OPENAI_API_BASE_URL, - "OPENAI_API_KEY": request.app.state.config.STT_OPENAI_API_KEY, - "ENGINE": request.app.state.config.STT_ENGINE, - "MODEL": request.app.state.config.STT_MODEL, - "SUPPORTED_CONTENT_TYPES": request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, - "WHISPER_MODEL": request.app.state.config.WHISPER_MODEL, - "DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY, - "AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY, - "AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION, - "AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES, - "AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL, - "AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, - "MISTRAL_API_KEY": request.app.state.config.AUDIO_STT_MISTRAL_API_KEY, - "MISTRAL_API_BASE_URL": request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL, - "MISTRAL_USE_CHAT_COMPLETIONS": request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, + 'stt': { + 'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL, + 'OPENAI_API_KEY': request.app.state.config.STT_OPENAI_API_KEY, + 'ENGINE': request.app.state.config.STT_ENGINE, + 'MODEL': request.app.state.config.STT_MODEL, + 'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, + 'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL, + 'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY, + 'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY, + 'AZURE_REGION': request.app.state.config.AUDIO_STT_AZURE_REGION, + 'AZURE_LOCALES': request.app.state.config.AUDIO_STT_AZURE_LOCALES, + 'AZURE_BASE_URL': request.app.state.config.AUDIO_STT_AZURE_BASE_URL, + 'AZURE_MAX_SPEAKERS': request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, + 'MISTRAL_API_KEY': request.app.state.config.AUDIO_STT_MISTRAL_API_KEY, + 'MISTRAL_API_BASE_URL': request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL, + 'MISTRAL_USE_CHAT_COMPLETIONS': request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, }, } -@router.post("/config/update") -async def update_audio_config( - request: Request, form_data: AudioConfigUpdateForm, user=Depends(get_admin_user) -): +@router.post('/config/update') +async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm, user=Depends(get_admin_user)): request.app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL request.app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY request.app.state.config.TTS_OPENAI_PARAMS = form_data.tts.OPENAI_PARAMS @@ -242,20 +240,14 @@ async def update_audio_config( request.app.state.config.TTS_VOICE = form_data.tts.VOICE request.app.state.config.TTS_SPLIT_ON = form_data.tts.SPLIT_ON request.app.state.config.TTS_AZURE_SPEECH_REGION = form_data.tts.AZURE_SPEECH_REGION - request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = ( - form_data.tts.AZURE_SPEECH_BASE_URL - ) - request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = ( - form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT - ) + request.app.state.config.TTS_AZURE_SPEECH_BASE_URL = form_data.tts.AZURE_SPEECH_BASE_URL + request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT = form_data.tts.AZURE_SPEECH_OUTPUT_FORMAT request.app.state.config.STT_OPENAI_API_BASE_URL = form_data.stt.OPENAI_API_BASE_URL request.app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY request.app.state.config.STT_ENGINE = form_data.stt.ENGINE request.app.state.config.STT_MODEL = form_data.stt.MODEL - request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = ( - form_data.stt.SUPPORTED_CONTENT_TYPES - ) + request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = form_data.stt.SUPPORTED_CONTENT_TYPES request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY @@ -263,18 +255,12 @@ async def update_audio_config( request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL - request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = ( - form_data.stt.AZURE_MAX_SPEAKERS - ) + request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = form_data.stt.AZURE_MAX_SPEAKERS request.app.state.config.AUDIO_STT_MISTRAL_API_KEY = form_data.stt.MISTRAL_API_KEY - request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = ( - form_data.stt.MISTRAL_API_BASE_URL - ) - request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = ( - form_data.stt.MISTRAL_USE_CHAT_COMPLETIONS - ) - - if request.app.state.config.STT_ENGINE == "": + request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL = form_data.stt.MISTRAL_API_BASE_URL + request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS = form_data.stt.MISTRAL_USE_CHAT_COMPLETIONS + + if request.app.state.config.STT_ENGINE == '': request.app.state.faster_whisper_model = set_faster_whisper_model( form_data.stt.WHISPER_MODEL, WHISPER_MODEL_AUTO_UPDATE ) @@ -282,35 +268,35 @@ async def update_audio_config( request.app.state.faster_whisper_model = None return { - "tts": { - "ENGINE": request.app.state.config.TTS_ENGINE, - "MODEL": request.app.state.config.TTS_MODEL, - "VOICE": request.app.state.config.TTS_VOICE, - "OPENAI_API_BASE_URL": request.app.state.config.TTS_OPENAI_API_BASE_URL, - "OPENAI_API_KEY": request.app.state.config.TTS_OPENAI_API_KEY, - "OPENAI_PARAMS": request.app.state.config.TTS_OPENAI_PARAMS, - "API_KEY": request.app.state.config.TTS_API_KEY, - "SPLIT_ON": request.app.state.config.TTS_SPLIT_ON, - "AZURE_SPEECH_REGION": request.app.state.config.TTS_AZURE_SPEECH_REGION, - "AZURE_SPEECH_BASE_URL": request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, - "AZURE_SPEECH_OUTPUT_FORMAT": request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, + 'tts': { + 'ENGINE': request.app.state.config.TTS_ENGINE, + 'MODEL': request.app.state.config.TTS_MODEL, + 'VOICE': request.app.state.config.TTS_VOICE, + 'OPENAI_API_BASE_URL': request.app.state.config.TTS_OPENAI_API_BASE_URL, + 'OPENAI_API_KEY': request.app.state.config.TTS_OPENAI_API_KEY, + 'OPENAI_PARAMS': request.app.state.config.TTS_OPENAI_PARAMS, + 'API_KEY': request.app.state.config.TTS_API_KEY, + 'SPLIT_ON': request.app.state.config.TTS_SPLIT_ON, + 'AZURE_SPEECH_REGION': request.app.state.config.TTS_AZURE_SPEECH_REGION, + 'AZURE_SPEECH_BASE_URL': request.app.state.config.TTS_AZURE_SPEECH_BASE_URL, + 'AZURE_SPEECH_OUTPUT_FORMAT': request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT, }, - "stt": { - "OPENAI_API_BASE_URL": request.app.state.config.STT_OPENAI_API_BASE_URL, - "OPENAI_API_KEY": request.app.state.config.STT_OPENAI_API_KEY, - "ENGINE": request.app.state.config.STT_ENGINE, - "MODEL": request.app.state.config.STT_MODEL, - "SUPPORTED_CONTENT_TYPES": request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, - "WHISPER_MODEL": request.app.state.config.WHISPER_MODEL, - "DEEPGRAM_API_KEY": request.app.state.config.DEEPGRAM_API_KEY, - "AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY, - "AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION, - "AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES, - "AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL, - "AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, - "MISTRAL_API_KEY": request.app.state.config.AUDIO_STT_MISTRAL_API_KEY, - "MISTRAL_API_BASE_URL": request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL, - "MISTRAL_USE_CHAT_COMPLETIONS": request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, + 'stt': { + 'OPENAI_API_BASE_URL': request.app.state.config.STT_OPENAI_API_BASE_URL, + 'OPENAI_API_KEY': request.app.state.config.STT_OPENAI_API_KEY, + 'ENGINE': request.app.state.config.STT_ENGINE, + 'MODEL': request.app.state.config.STT_MODEL, + 'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES, + 'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL, + 'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY, + 'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY, + 'AZURE_REGION': request.app.state.config.AUDIO_STT_AZURE_REGION, + 'AZURE_LOCALES': request.app.state.config.AUDIO_STT_AZURE_LOCALES, + 'AZURE_BASE_URL': request.app.state.config.AUDIO_STT_AZURE_BASE_URL, + 'AZURE_MAX_SPEAKERS': request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS, + 'MISTRAL_API_KEY': request.app.state.config.AUDIO_STT_MISTRAL_API_KEY, + 'MISTRAL_API_BASE_URL': request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL, + 'MISTRAL_USE_CHAT_COMPLETIONS': request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS, }, } @@ -320,27 +306,23 @@ def load_speech_pipeline(request): from datasets import load_dataset if request.app.state.speech_synthesiser is None: - request.app.state.speech_synthesiser = pipeline( - "text-to-speech", "microsoft/speecht5_tts" - ) + request.app.state.speech_synthesiser = pipeline('text-to-speech', 'microsoft/speecht5_tts') if request.app.state.speech_speaker_embeddings_dataset is None: request.app.state.speech_speaker_embeddings_dataset = load_dataset( - "Matthijs/cmu-arctic-xvectors", split="validation" + 'Matthijs/cmu-arctic-xvectors', split='validation' ) -@router.post("/speech") +@router.post('/speech') async def speech(request: Request, user=Depends(get_verified_user)): - if request.app.state.config.TTS_ENGINE == "": + if request.app.state.config.TTS_ENGINE == '': raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - if user.role != "admin" and not has_permission( - user.id, "chat.tts", request.app.state.config.USER_PERMISSIONS - ): + if user.role != 'admin' and not has_permission(user.id, 'chat.tts', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -349,12 +331,12 @@ async def speech(request: Request, user=Depends(get_verified_user)): body = await request.body() name = hashlib.sha256( body - + str(request.app.state.config.TTS_ENGINE).encode("utf-8") - + str(request.app.state.config.TTS_MODEL).encode("utf-8") + + str(request.app.state.config.TTS_ENGINE).encode('utf-8') + + str(request.app.state.config.TTS_MODEL).encode('utf-8') ).hexdigest() - file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3") - file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json") + file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3') + file_body_path = SPEECH_CACHE_DIR.joinpath(f'{name}.json') # Check if the file already exists in the cache if file_path.is_file(): @@ -362,34 +344,32 @@ async def speech(request: Request, user=Depends(get_verified_user)): payload = None try: - payload = json.loads(body.decode("utf-8")) + payload = json.loads(body.decode('utf-8')) except Exception as e: log.exception(e) - raise HTTPException(status_code=400, detail="Invalid JSON payload") + raise HTTPException(status_code=400, detail='Invalid JSON payload') r = None - if request.app.state.config.TTS_ENGINE == "openai": - payload["model"] = request.app.state.config.TTS_MODEL + if request.app.state.config.TTS_ENGINE == 'openai': + payload['model'] = request.app.state.config.TTS_MODEL try: timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) - async with aiohttp.ClientSession( - timeout=timeout, trust_env=True - ) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: payload = { **payload, **(request.app.state.config.TTS_OPENAI_PARAMS or {}), } headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}", + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {request.app.state.config.TTS_OPENAI_API_KEY}', } if ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) r = await session.post( - url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", + url=f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech', json=payload, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -397,10 +377,10 @@ async def speech(request: Request, user=Depends(get_verified_user)): r.raise_for_status() - async with aiofiles.open(file_path, "wb") as f: + async with aiofiles.open(file_path, 'wb') as f: await f.write(await r.read()) - async with aiofiles.open(file_body_path, "w") as f: + async with aiofiles.open(file_body_path, 'w') as f: await f.write(json.dumps(payload)) return FileResponse(file_path) @@ -410,57 +390,55 @@ async def speech(request: Request, user=Depends(get_verified_user)): detail = None status_code = 500 - detail = f"Open WebUI: Server Connection Error" + detail = f'Open WebUI: Server Connection Error' if r is not None: status_code = r.status try: res = await r.json() - if "error" in res: - detail = f"External: {res['error']}" + if 'error' in res: + detail = f'External: {res["error"]}' except Exception: - detail = f"External: {e}" + detail = f'External: {e}' raise HTTPException( status_code=status_code, detail=detail, ) - elif request.app.state.config.TTS_ENGINE == "elevenlabs": - voice_id = payload.get("voice", "") + elif request.app.state.config.TTS_ENGINE == 'elevenlabs': + voice_id = payload.get('voice', '') if voice_id not in get_available_voices(request): raise HTTPException( status_code=400, - detail="Invalid voice id", + detail='Invalid voice id', ) try: timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) - async with aiohttp.ClientSession( - timeout=timeout, trust_env=True - ) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.post( - f"{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}", + f'{ELEVENLABS_API_BASE_URL}/v1/text-to-speech/{voice_id}', json={ - "text": payload["input"], - "model_id": request.app.state.config.TTS_MODEL, - "voice_settings": {"stability": 0.5, "similarity_boost": 0.5}, + 'text': payload['input'], + 'model_id': request.app.state.config.TTS_MODEL, + 'voice_settings': {'stability': 0.5, 'similarity_boost': 0.5}, }, headers={ - "Accept": "audio/mpeg", - "Content-Type": "application/json", - "xi-api-key": request.app.state.config.TTS_API_KEY, + 'Accept': 'audio/mpeg', + 'Content-Type': 'application/json', + 'xi-api-key': request.app.state.config.TTS_API_KEY, }, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() - async with aiofiles.open(file_path, "wb") as f: + async with aiofiles.open(file_path, 'wb') as f: await f.write(await r.read()) - async with aiofiles.open(file_body_path, "w") as f: + async with aiofiles.open(file_body_path, 'w') as f: await f.write(json.dumps(payload)) return FileResponse(file_path) @@ -472,54 +450,51 @@ async def speech(request: Request, user=Depends(get_verified_user)): try: if r.status != 200: res = await r.json() - if "error" in res: - detail = f"External: {res['error'].get('message', '')}" + if 'error' in res: + detail = f'External: {res["error"].get("message", "")}' except Exception: - detail = f"External: {e}" + detail = f'External: {e}' raise HTTPException( - status_code=getattr(r, "status", 500) if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + status_code=getattr(r, 'status', 500) if r else 500, + detail=detail if detail else 'Open WebUI: Server Connection Error', ) - elif request.app.state.config.TTS_ENGINE == "azure": + elif request.app.state.config.TTS_ENGINE == 'azure': try: - payload = json.loads(body.decode("utf-8")) + payload = json.loads(body.decode('utf-8')) except Exception as e: log.exception(e) - raise HTTPException(status_code=400, detail="Invalid JSON payload") + raise HTTPException(status_code=400, detail='Invalid JSON payload') - region = request.app.state.config.TTS_AZURE_SPEECH_REGION or "eastus" + region = request.app.state.config.TTS_AZURE_SPEECH_REGION or 'eastus' base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL language = request.app.state.config.TTS_VOICE - locale = "-".join(request.app.state.config.TTS_VOICE.split("-")[:2]) + locale = '-'.join(request.app.state.config.TTS_VOICE.split('-')[:2]) output_format = request.app.state.config.TTS_AZURE_SPEECH_OUTPUT_FORMAT try: data = f""" - {html.escape(payload["input"])} + {html.escape(payload['input'])} """ timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) - async with aiohttp.ClientSession( - timeout=timeout, trust_env=True - ) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.post( - (base_url or f"https://{region}.tts.speech.microsoft.com") - + "/cognitiveservices/v1", + (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/v1', headers={ - "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY, - "Content-Type": "application/ssml+xml", - "X-Microsoft-OutputFormat": output_format, + 'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY, + 'Content-Type': 'application/ssml+xml', + 'X-Microsoft-OutputFormat': output_format, }, data=data, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: r.raise_for_status() - async with aiofiles.open(file_path, "wb") as f: + async with aiofiles.open(file_path, 'wb') as f: await f.write(await r.read()) - async with aiofiles.open(file_body_path, "w") as f: + async with aiofiles.open(file_body_path, 'w') as f: await f.write(json.dumps(payload)) return FileResponse(file_path) @@ -531,23 +506,23 @@ async def speech(request: Request, user=Depends(get_verified_user)): try: if r.status != 200: res = await r.json() - if "error" in res: - detail = f"External: {res['error'].get('message', '')}" + if 'error' in res: + detail = f'External: {res["error"].get("message", "")}' except Exception: - detail = f"External: {e}" + detail = f'External: {e}' raise HTTPException( - status_code=getattr(r, "status", 500) if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + status_code=getattr(r, 'status', 500) if r else 500, + detail=detail if detail else 'Open WebUI: Server Connection Error', ) - elif request.app.state.config.TTS_ENGINE == "transformers": + elif request.app.state.config.TTS_ENGINE == 'transformers': payload = None try: - payload = json.loads(body.decode("utf-8")) + payload = json.loads(body.decode('utf-8')) except Exception as e: log.exception(e) - raise HTTPException(status_code=400, detail="Invalid JSON payload") + raise HTTPException(status_code=400, detail='Invalid JSON payload') import torch import soundfile as sf @@ -558,24 +533,20 @@ async def speech(request: Request, user=Depends(get_verified_user)): speaker_index = 6799 try: - speaker_index = embeddings_dataset["filename"].index( - request.app.state.config.TTS_MODEL - ) + speaker_index = embeddings_dataset['filename'].index(request.app.state.config.TTS_MODEL) except Exception: pass - speaker_embedding = torch.tensor( - embeddings_dataset[speaker_index]["xvector"] - ).unsqueeze(0) + speaker_embedding = torch.tensor(embeddings_dataset[speaker_index]['xvector']).unsqueeze(0) speech = request.app.state.speech_synthesiser( - payload["input"], - forward_params={"speaker_embeddings": speaker_embedding}, + payload['input'], + forward_params={'speaker_embeddings': speaker_embedding}, ) - sf.write(file_path, speech["audio"], samplerate=speech["sampling_rate"]) + sf.write(file_path, speech['audio'], samplerate=speech['sampling_rate']) - async with aiofiles.open(file_body_path, "w") as f: + async with aiofiles.open(file_body_path, 'w') as f: await f.write(json.dumps(payload)) return FileResponse(file_path) @@ -584,20 +555,18 @@ async def speech(request: Request, user=Depends(get_verified_user)): def transcription_handler(request, file_path, metadata, user=None): filename = os.path.basename(file_path) file_dir = os.path.dirname(file_path) - id = filename.split(".")[0] + id = filename.split('.')[0] metadata = metadata or {} languages = [ - metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE, + metadata.get('language', None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE, None, # Always fallback to None in case transcription fails ] - if request.app.state.config.STT_ENGINE == "": + if request.app.state.config.STT_ENGINE == '': if request.app.state.faster_whisper_model is None: - request.app.state.faster_whisper_model = set_faster_whisper_model( - request.app.state.config.WHISPER_MODEL - ) + request.app.state.faster_whisper_model = set_faster_whisper_model(request.app.state.config.WHISPER_MODEL) model = request.app.state.faster_whisper_model segments, info = model.transcribe( @@ -607,43 +576,38 @@ def transcription_handler(request, file_path, metadata, user=None): language=languages[0], multilingual=WHISPER_MULTILINGUAL, ) - log.info( - "Detected language '%s' with probability %f" - % (info.language, info.language_probability) - ) + log.info("Detected language '%s' with probability %f" % (info.language, info.language_probability)) - transcript = "".join([segment.text for segment in list(segments)]) - data = {"text": transcript.strip()} + transcript = ''.join([segment.text for segment in list(segments)]) + data = {'text': transcript.strip()} # save the transcript to a json file - transcript_file = f"{file_dir}/{id}.json" - with open(transcript_file, "w") as f: + transcript_file = f'{file_dir}/{id}.json' + with open(transcript_file, 'w') as f: json.dump(data, f) log.debug(data) return data - elif request.app.state.config.STT_ENGINE == "openai": + elif request.app.state.config.STT_ENGINE == 'openai': r = None try: for language in languages: payload = { - "model": request.app.state.config.STT_MODEL, + 'model': request.app.state.config.STT_MODEL, } if language: - payload["language"] = language + payload['language'] = language - headers = { - "Authorization": f"Bearer {request.app.state.config.STT_OPENAI_API_KEY}" - } + headers = {'Authorization': f'Bearer {request.app.state.config.STT_OPENAI_API_KEY}'} if user and ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) - with open(file_path, "rb") as audio_file: + with open(file_path, 'rb') as audio_file: r = requests.post( - url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions", + url=f'{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions', headers=headers, - files={"file": (filename, audio_file)}, + files={'file': (filename, audio_file)}, data=payload, timeout=AIOHTTP_CLIENT_TIMEOUT, ) @@ -656,8 +620,8 @@ def transcription_handler(request, file_path, metadata, user=None): data = r.json() # save the transcript to a json file - transcript_file = f"{file_dir}/{id}.json" - with open(transcript_file, "w") as f: + transcript_file = f'{file_dir}/{id}.json' + with open(transcript_file, 'w') as f: json.dump(data, f) return data @@ -668,41 +632,41 @@ def transcription_handler(request, file_path, metadata, user=None): if r is not None: try: res = r.json() - if "error" in res: - detail = f"External: {res['error'].get('message', '')}" + if 'error' in res: + detail = f'External: {res["error"].get("message", "")}' except Exception: - detail = f"External: {e}" + detail = f'External: {e}' - raise Exception(detail if detail else "Open WebUI: Server Connection Error") + raise Exception(detail if detail else 'Open WebUI: Server Connection Error') - elif request.app.state.config.STT_ENGINE == "deepgram": + elif request.app.state.config.STT_ENGINE == 'deepgram': try: # Determine the MIME type of the file mime, _ = mimetypes.guess_type(file_path) if not mime: - mime = "audio/wav" # fallback to wav if undetectable + mime = 'audio/wav' # fallback to wav if undetectable # Read the audio file - with open(file_path, "rb") as f: + with open(file_path, 'rb') as f: file_data = f.read() # Build headers and parameters headers = { - "Authorization": f"Token {request.app.state.config.DEEPGRAM_API_KEY}", - "Content-Type": mime, + 'Authorization': f'Token {request.app.state.config.DEEPGRAM_API_KEY}', + 'Content-Type': mime, } for language in languages: params = {} if request.app.state.config.STT_MODEL: - params["model"] = request.app.state.config.STT_MODEL + params['model'] = request.app.state.config.STT_MODEL if language: - params["language"] = language + params['language'] = language # Make request to Deepgram API r = requests.post( - "https://api.deepgram.com/v1/listen?smart_format=true", + 'https://api.deepgram.com/v1/listen?smart_format=true', headers=headers, params=params, data=file_data, @@ -718,19 +682,15 @@ def transcription_handler(request, file_path, metadata, user=None): # Extract transcript from Deepgram response try: - transcript = response_data["results"]["channels"][0]["alternatives"][ - 0 - ].get("transcript", "") + transcript = response_data['results']['channels'][0]['alternatives'][0].get('transcript', '') except (KeyError, IndexError) as e: - log.error(f"Malformed response from Deepgram: {str(e)}") - raise Exception( - "Failed to parse Deepgram response - unexpected response format" - ) - data = {"text": transcript.strip()} + log.error(f'Malformed response from Deepgram: {str(e)}') + raise Exception('Failed to parse Deepgram response - unexpected response format') + data = {'text': transcript.strip()} # Save transcript - transcript_file = f"{file_dir}/{id}.json" - with open(transcript_file, "w") as f: + transcript_file = f'{file_dir}/{id}.json' + with open(transcript_file, 'w') as f: json.dump(data, f) return data @@ -741,16 +701,16 @@ def transcription_handler(request, file_path, metadata, user=None): if r is not None: try: res = r.json() - if "error" in res: - detail = f"External: {res['error'].get('message', '')}" + if 'error' in res: + detail = f'External: {res["error"].get("message", "")}' except Exception: - detail = f"External: {e}" - raise Exception(detail if detail else "Open WebUI: Server Connection Error") + detail = f'External: {e}' + raise Exception(detail if detail else 'Open WebUI: Server Connection Error') - elif request.app.state.config.STT_ENGINE == "azure": + elif request.app.state.config.STT_ENGINE == 'azure': # Check file exists and size if not os.path.exists(file_path): - raise HTTPException(status_code=400, detail="Audio file not found") + raise HTTPException(status_code=400, detail='Audio file not found') # Check file size (Azure has a larger limit of 200MB) file_size = os.path.getsize(file_path) @@ -761,7 +721,7 @@ def transcription_handler(request, file_path, metadata, user=None): ) api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY - region = request.app.state.config.AUDIO_STT_AZURE_REGION or "eastus" + region = request.app.state.config.AUDIO_STT_AZURE_REGION or 'eastus' locales = request.app.state.config.AUDIO_STT_AZURE_LOCALES base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS or 3 @@ -769,36 +729,36 @@ def transcription_handler(request, file_path, metadata, user=None): # IF NO LOCALES, USE DEFAULTS if len(locales) < 2: locales = [ - "en-US", - "es-ES", - "es-MX", - "fr-FR", - "hi-IN", - "it-IT", - "de-DE", - "en-GB", - "en-IN", - "ja-JP", - "ko-KR", - "pt-BR", - "zh-CN", + 'en-US', + 'es-ES', + 'es-MX', + 'fr-FR', + 'hi-IN', + 'it-IT', + 'de-DE', + 'en-GB', + 'en-IN', + 'ja-JP', + 'ko-KR', + 'pt-BR', + 'zh-CN', ] - locales = ",".join(locales) + locales = ','.join(locales) if not api_key or not region: raise HTTPException( status_code=400, - detail="Azure API key is required for Azure STT", + detail='Azure API key is required for Azure STT', ) r = None try: # Prepare the request data = { - "definition": json.dumps( + 'definition': json.dumps( { - "locales": locales.split(","), - "diarization": {"maxSpeakers": max_speakers, "enabled": True}, + 'locales': locales.split(','), + 'diarization': {'maxSpeakers': max_speakers, 'enabled': True}, } if locales else {} @@ -806,17 +766,17 @@ def transcription_handler(request, file_path, metadata, user=None): } url = ( - base_url or f"https://{region}.api.cognitive.microsoft.com" - ) + "/speechtotext/transcriptions:transcribe?api-version=2024-11-15" + base_url or f'https://{region}.api.cognitive.microsoft.com' + ) + '/speechtotext/transcriptions:transcribe?api-version=2024-11-15' # Use context manager to ensure file is properly closed - with open(file_path, "rb") as audio_file: + with open(file_path, 'rb') as audio_file: r = requests.post( url=url, - files={"audio": audio_file}, + files={'audio': audio_file}, data=data, headers={ - "Ocp-Apim-Subscription-Key": api_key, + 'Ocp-Apim-Subscription-Key': api_key, }, timeout=AIOHTTP_CLIENT_TIMEOUT, ) @@ -825,100 +785,93 @@ def transcription_handler(request, file_path, metadata, user=None): response = r.json() # Extract transcript from response - if not response.get("combinedPhrases"): - raise ValueError("No transcription found in response") + if not response.get('combinedPhrases'): + raise ValueError('No transcription found in response') # Get the full transcript from combinedPhrases - transcript = response["combinedPhrases"][0].get("text", "").strip() + transcript = response['combinedPhrases'][0].get('text', '').strip() if not transcript: - raise ValueError("Empty transcript in response") + raise ValueError('Empty transcript in response') - data = {"text": transcript} + data = {'text': transcript} # Save transcript to json file (consistent with other providers) - transcript_file = f"{file_dir}/{id}.json" - with open(transcript_file, "w") as f: + transcript_file = f'{file_dir}/{id}.json' + with open(transcript_file, 'w') as f: json.dump(data, f) log.debug(data) return data except (KeyError, IndexError, ValueError) as e: - log.exception("Error parsing Azure response") + log.exception('Error parsing Azure response') raise HTTPException( status_code=500, - detail=f"Failed to parse Azure response: {str(e)}", + detail=f'Failed to parse Azure response: {str(e)}', ) except requests.exceptions.RequestException as e: log.exception(e) detail = None - status_code = getattr(r, "status_code", 500) if r else 500 + status_code = getattr(r, 'status_code', 500) if r else 500 try: if r is not None and r.status_code != 200: res = r.json() # Azure returns {"code": "...", "message": "...", "innerError": {...}} - if "code" in res and "message" in res: - azure_code = res.get("innerError", {}).get("code", res["code"]) + if 'code' in res and 'message' in res: + azure_code = res.get('innerError', {}).get('code', res['code']) user_facing_codes = { - "EmptyAudioFile", - "AudioLengthLimitExceeded", - "NoLanguageIdentified", - "MultipleLanguagesIdentified", + 'EmptyAudioFile', + 'AudioLengthLimitExceeded', + 'NoLanguageIdentified', + 'MultipleLanguagesIdentified', } if azure_code in user_facing_codes: - detail = res["message"] + detail = res['message'] else: - log.error( - f"Azure STT error [{azure_code}]: {res['message']}" - ) - detail = "An error occurred during transcription." - elif "error" in res: - detail = f"External: {res['error'].get('message', '')}" + log.error(f'Azure STT error [{azure_code}]: {res["message"]}') + detail = 'An error occurred during transcription.' + elif 'error' in res: + detail = f'External: {res["error"].get("message", "")}' except Exception: - detail = f"External: {e}" + detail = f'External: {e}' raise HTTPException( status_code=status_code, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) - elif request.app.state.config.STT_ENGINE == "mistral": + elif request.app.state.config.STT_ENGINE == 'mistral': # Check file exists if not os.path.exists(file_path): - raise HTTPException(status_code=400, detail="Audio file not found") + raise HTTPException(status_code=400, detail='Audio file not found') # Check file size file_size = os.path.getsize(file_path) if file_size > MAX_FILE_SIZE: raise HTTPException( status_code=400, - detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB}MB", + detail=f'File size exceeds limit of {MAX_FILE_SIZE_MB}MB', ) api_key = request.app.state.config.AUDIO_STT_MISTRAL_API_KEY - api_base_url = ( - request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL - or "https://api.mistral.ai/v1" - ) - use_chat_completions = ( - request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS - ) + api_base_url = request.app.state.config.AUDIO_STT_MISTRAL_API_BASE_URL or 'https://api.mistral.ai/v1' + use_chat_completions = request.app.state.config.AUDIO_STT_MISTRAL_USE_CHAT_COMPLETIONS if not api_key: raise HTTPException( status_code=400, - detail="Mistral API key is required for Mistral STT", + detail='Mistral API key is required for Mistral STT', ) r = None try: # Use voxtral-mini-latest as the default model for transcription - model = request.app.state.config.STT_MODEL or "voxtral-mini-latest" + model = request.app.state.config.STT_MODEL or 'voxtral-mini-latest' log.info( - f"Mistral STT - model: {model}, " - f"method: {'chat_completions' if use_chat_completions else 'transcriptions'}" + f'Mistral STT - model: {model}, ' + f'method: {"chat_completions" if use_chat_completions else "transcriptions"}' ) if use_chat_completions: @@ -927,42 +880,42 @@ def transcription_handler(request, file_path, metadata, user=None): audio_file_to_use = file_path if is_audio_conversion_required(file_path): - log.debug("Converting audio to mp3 for chat completions API") + log.debug('Converting audio to mp3 for chat completions API') converted_path = convert_audio_to_mp3(file_path) if converted_path: audio_file_to_use = converted_path else: - log.error("Audio conversion failed") + log.error('Audio conversion failed') raise HTTPException( status_code=500, - detail="Audio conversion failed. Chat completions API requires mp3 or wav format.", + detail='Audio conversion failed. Chat completions API requires mp3 or wav format.', ) # Read and encode audio file as base64 - with open(audio_file_to_use, "rb") as audio_file: - audio_base64 = base64.b64encode(audio_file.read()).decode("utf-8") + with open(audio_file_to_use, 'rb') as audio_file: + audio_base64 = base64.b64encode(audio_file.read()).decode('utf-8') # Prepare chat completions request - url = f"{api_base_url}/chat/completions" + url = f'{api_base_url}/chat/completions' # Add language instruction if specified - language = metadata.get("language", None) if metadata else None + language = metadata.get('language', None) if metadata else None if language: - text_instruction = f"Transcribe this audio exactly as spoken in {language}. Do not translate it." + text_instruction = f'Transcribe this audio exactly as spoken in {language}. Do not translate it.' else: - text_instruction = "Transcribe this audio exactly as spoken in its original language. Do not translate it to another language." + text_instruction = 'Transcribe this audio exactly as spoken in its original language. Do not translate it to another language.' payload = { - "model": model, - "messages": [ + 'model': model, + 'messages': [ { - "role": "user", - "content": [ + 'role': 'user', + 'content': [ { - "type": "input_audio", - "input_audio": audio_base64, + 'type': 'input_audio', + 'input_audio': audio_base64, }, - {"type": "text", "text": text_instruction}, + {'type': 'text', 'text': text_instruction}, ], } ], @@ -972,8 +925,8 @@ def transcription_handler(request, file_path, metadata, user=None): url=url, json=payload, headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', }, timeout=AIOHTTP_CLIENT_TIMEOUT, ) @@ -982,42 +935,37 @@ def transcription_handler(request, file_path, metadata, user=None): response = r.json() # Extract transcript from chat completion response - transcript = ( - response.get("choices", [{}])[0] - .get("message", {}) - .get("content", "") - .strip() - ) + transcript = response.get('choices', [{}])[0].get('message', {}).get('content', '').strip() if not transcript: - raise ValueError("Empty transcript in response") + raise ValueError('Empty transcript in response') - data = {"text": transcript} + data = {'text': transcript} else: # Use dedicated transcriptions API - url = f"{api_base_url}/audio/transcriptions" + url = f'{api_base_url}/audio/transcriptions' # Determine the MIME type mime_type, _ = mimetypes.guess_type(file_path) if not mime_type: - mime_type = "audio/webm" + mime_type = 'audio/webm' # Use context manager to ensure file is properly closed - with open(file_path, "rb") as audio_file: - files = {"file": (filename, audio_file, mime_type)} - data_form = {"model": model} + with open(file_path, 'rb') as audio_file: + files = {'file': (filename, audio_file, mime_type)} + data_form = {'model': model} # Add language if specified in metadata - language = metadata.get("language", None) if metadata else None + language = metadata.get('language', None) if metadata else None if language: - data_form["language"] = language + data_form['language'] = language r = requests.post( url=url, files=files, data=data_form, headers={ - "Authorization": f"Bearer {api_key}", + 'Authorization': f'Bearer {api_key}', }, timeout=AIOHTTP_CLIENT_TIMEOUT, ) @@ -1026,25 +974,25 @@ def transcription_handler(request, file_path, metadata, user=None): response = r.json() # Extract transcript from response - transcript = response.get("text", "").strip() + transcript = response.get('text', '').strip() if not transcript: - raise ValueError("Empty transcript in response") + raise ValueError('Empty transcript in response') - data = {"text": transcript} + data = {'text': transcript} # Save transcript to json file (consistent with other providers) - transcript_file = f"{file_dir}/{id}.json" - with open(transcript_file, "w") as f: + transcript_file = f'{file_dir}/{id}.json' + with open(transcript_file, 'w') as f: json.dump(data, f) log.debug(data) return data except ValueError as e: - log.exception("Error parsing Mistral response") + log.exception('Error parsing Mistral response') raise HTTPException( status_code=500, - detail=f"Failed to parse Mistral response: {str(e)}", + detail=f'Failed to parse Mistral response: {str(e)}', ) except requests.exceptions.RequestException as e: log.exception(e) @@ -1053,23 +1001,21 @@ def transcription_handler(request, file_path, metadata, user=None): try: if r is not None and r.status_code != 200: res = r.json() - if "error" in res: - detail = f"External: {res['error'].get('message', '')}" + if 'error' in res: + detail = f'External: {res["error"].get("message", "")}' else: - detail = f"External: {r.text}" + detail = f'External: {r.text}' except Exception: - detail = f"External: {e}" + detail = f'External: {e}' raise HTTPException( - status_code=getattr(r, "status_code", 500) if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + status_code=getattr(r, 'status_code', 500) if r else 500, + detail=detail if detail else 'Open WebUI: Server Connection Error', ) -def transcribe( - request: Request, file_path: str, metadata: Optional[dict] = None, user=None -): - log.info(f"transcribe: {file_path} {metadata}") +def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None, user=None): + log.info(f'transcribe: {file_path} {metadata}') if is_audio_conversion_required(file_path): file_path = convert_audio_to_mp3(file_path) @@ -1082,7 +1028,7 @@ def transcribe( # Always produce a list of chunk paths (could be one entry if small) try: chunk_paths = split_audio(file_path, MAX_FILE_SIZE) - print(f"Chunk paths: {chunk_paths}") + print(f'Chunk paths: {chunk_paths}') except Exception as e: log.exception(e) raise HTTPException( @@ -1095,9 +1041,7 @@ def transcribe( with ThreadPoolExecutor() as executor: # Submit tasks for each chunk_path futures = [ - executor.submit( - transcription_handler, request, chunk_path, metadata, user - ) + executor.submit(transcription_handler, request, chunk_path, metadata, user) for chunk_path in chunk_paths ] # Gather results as they complete @@ -1109,7 +1053,7 @@ def transcribe( except Exception as transcribe_exc: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error transcribing chunk: {transcribe_exc}", + detail=f'Error transcribing chunk: {transcribe_exc}', ) finally: # Clean up only the temporary chunks, never the original file @@ -1121,22 +1065,20 @@ def transcribe( pass return { - "text": " ".join([result["text"] for result in results]), + 'text': ' '.join([result['text'] for result in results]), } def compress_audio(file_path): if os.path.getsize(file_path) > MAX_FILE_SIZE: - id = os.path.splitext(os.path.basename(file_path))[ - 0 - ] # Handles names with multiple dots + id = os.path.splitext(os.path.basename(file_path))[0] # Handles names with multiple dots file_dir = os.path.dirname(file_path) audio = AudioSegment.from_file(file_path) audio = audio.set_frame_rate(16000).set_channels(1) # Compress audio - compressed_path = os.path.join(file_dir, f"{id}_compressed.mp3") - audio.export(compressed_path, format="mp3", bitrate="32k") + compressed_path = os.path.join(file_dir, f'{id}_compressed.mp3') + audio.export(compressed_path, format='mp3', bitrate='32k') # log.debug(f"Compressed audio to {compressed_path}") # Uncomment if log is defined return compressed_path @@ -1144,7 +1086,7 @@ def compress_audio(file_path): return file_path -def split_audio(file_path, max_bytes, format="mp3", bitrate="32k"): +def split_audio(file_path, max_bytes, format='mp3', bitrate='32k'): """ Splits audio into chunks not exceeding max_bytes. Returns a list of chunk file paths. If audio fits, returns list with original path. @@ -1167,7 +1109,7 @@ def split_audio(file_path, max_bytes, format="mp3", bitrate="32k"): while start < duration_ms: end = min(start + approx_chunk_ms, duration_ms) chunk = audio[start:end] - chunk_path = f"{base}_chunk_{i}.{format}" + chunk_path = f'{base}_chunk_{i}.{format}' chunk.export(chunk_path, format=format, bitrate=bitrate) # Reduce chunk duration if still too large @@ -1178,7 +1120,7 @@ def split_audio(file_path, max_bytes, format="mp3", bitrate="32k"): if os.path.getsize(chunk_path) > max_bytes: os.remove(chunk_path) - raise Exception("Audio chunk cannot be reduced below max file size.") + raise Exception('Audio chunk cannot be reduced below max file size.') chunks.append(chunk_path) start = end @@ -1187,24 +1129,20 @@ def split_audio(file_path, max_bytes, format="mp3", bitrate="32k"): return chunks -@router.post("/transcriptions") +@router.post('/transcriptions') def transcription( request: Request, file: UploadFile = File(...), language: Optional[str] = Form(None), user=Depends(get_verified_user), ): - if user.role != "admin" and not has_permission( - user.id, "chat.stt", request.app.state.config.USER_PERMISSIONS - ): + if user.role != 'admin' and not has_permission(user.id, 'chat.stt', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - log.info(f"file.content_type: {file.content_type}") - stt_supported_content_types = getattr( - request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] - ) + log.info(f'file.content_type: {file.content_type}') + stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', []) if not strict_match_mime_type(stt_supported_content_types, file.content_type): raise HTTPException( @@ -1213,36 +1151,36 @@ def transcription( ) try: - safe_name = os.path.basename(file.filename) if file.filename else "" - ext = safe_name.rsplit(".", 1)[-1] if "." in safe_name else "" + safe_name = os.path.basename(file.filename) if file.filename else '' + ext = safe_name.rsplit('.', 1)[-1] if '.' in safe_name else '' id = uuid.uuid4() - filename = f"{id}.{ext}" + filename = f'{id}.{ext}' contents = file.file.read() - file_dir = f"{CACHE_DIR}/audio/transcriptions" + file_dir = f'{CACHE_DIR}/audio/transcriptions' os.makedirs(file_dir, exist_ok=True) - file_path = f"{file_dir}/{filename}" + file_path = f'{file_dir}/{filename}' # Defense-in-depth: ensure resolved path stays within intended directory if not os.path.realpath(file_path).startswith(os.path.realpath(file_dir)): - raise ValueError("Invalid file path detected") + raise ValueError('Invalid file path detected') - with open(file_path, "wb") as f: + with open(file_path, 'wb') as f: f.write(contents) try: metadata = None if language: - metadata = {"language": language} + metadata = {'language': language} result = transcribe(request, file_path, metadata, user) return { **result, - "filename": os.path.basename(file_path), + 'filename': os.path.basename(file_path), } except HTTPException: @@ -1252,7 +1190,7 @@ def transcription( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Transcription failed.", + detail='Transcription failed.', ) except HTTPException: @@ -1262,123 +1200,107 @@ def transcription( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Transcription failed.", + detail='Transcription failed.', ) def get_available_models(request: Request) -> list[dict]: available_models = [] - if request.app.state.config.TTS_ENGINE == "openai": + if request.app.state.config.TTS_ENGINE == 'openai': # Use custom endpoint if not using the official OpenAI API URL - if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith( - "https://api.openai.com" - ): + if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'): try: response = requests.get( - f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models", + f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models', timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, ) response.raise_for_status() data = response.json() - available_models = data.get("models", []) + available_models = data.get('models', []) except Exception as e: - log.error(f"Error fetching models from custom endpoint: {str(e)}") - available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] + log.error(f'Error fetching models from custom endpoint: {str(e)}') + available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}] else: - available_models = [{"id": "tts-1"}, {"id": "tts-1-hd"}] - elif request.app.state.config.TTS_ENGINE == "elevenlabs": + available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}] + elif request.app.state.config.TTS_ENGINE == 'elevenlabs': try: response = requests.get( - f"{ELEVENLABS_API_BASE_URL}/v1/models", + f'{ELEVENLABS_API_BASE_URL}/v1/models', headers={ - "xi-api-key": request.app.state.config.TTS_API_KEY, - "Content-Type": "application/json", + 'xi-api-key': request.app.state.config.TTS_API_KEY, + 'Content-Type': 'application/json', }, timeout=5, ) response.raise_for_status() models = response.json() - available_models = [ - {"name": model["name"], "id": model["model_id"]} for model in models - ] + available_models = [{'name': model['name'], 'id': model['model_id']} for model in models] except requests.RequestException as e: - log.error(f"Error fetching voices: {str(e)}") + log.error(f'Error fetching voices: {str(e)}') return available_models -@router.get("/models") +@router.get('/models') async def get_models(request: Request, user=Depends(get_verified_user)): - return {"models": get_available_models(request)} + return {'models': get_available_models(request)} def get_available_voices(request) -> dict: """Returns {voice_id: voice_name} dict""" available_voices = {} - if request.app.state.config.TTS_ENGINE == "openai": + if request.app.state.config.TTS_ENGINE == 'openai': # Use custom endpoint if not using the official OpenAI API URL - if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith( - "https://api.openai.com" - ): + if not request.app.state.config.TTS_OPENAI_API_BASE_URL.startswith('https://api.openai.com'): try: response = requests.get( - f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices", + f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices', timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, ) response.raise_for_status() data = response.json() - voices_list = data.get("voices", []) - available_voices = {voice["id"]: voice["name"] for voice in voices_list} + voices_list = data.get('voices', []) + available_voices = {voice['id']: voice['name'] for voice in voices_list} except Exception as e: - log.error(f"Error fetching voices from custom endpoint: {str(e)}") + log.error(f'Error fetching voices from custom endpoint: {str(e)}') available_voices = { - "alloy": "alloy", - "echo": "echo", - "fable": "fable", - "onyx": "onyx", - "nova": "nova", - "shimmer": "shimmer", + 'alloy': 'alloy', + 'echo': 'echo', + 'fable': 'fable', + 'onyx': 'onyx', + 'nova': 'nova', + 'shimmer': 'shimmer', } else: available_voices = { - "alloy": "alloy", - "echo": "echo", - "fable": "fable", - "onyx": "onyx", - "nova": "nova", - "shimmer": "shimmer", + 'alloy': 'alloy', + 'echo': 'echo', + 'fable': 'fable', + 'onyx': 'onyx', + 'nova': 'nova', + 'shimmer': 'shimmer', } - elif request.app.state.config.TTS_ENGINE == "elevenlabs": + elif request.app.state.config.TTS_ENGINE == 'elevenlabs': try: - available_voices = get_elevenlabs_voices( - api_key=request.app.state.config.TTS_API_KEY - ) + available_voices = get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY) except Exception: # Avoided @lru_cache with exception pass - elif request.app.state.config.TTS_ENGINE == "azure": + elif request.app.state.config.TTS_ENGINE == 'azure': try: region = request.app.state.config.TTS_AZURE_SPEECH_REGION base_url = request.app.state.config.TTS_AZURE_SPEECH_BASE_URL - url = ( - base_url or f"https://{region}.tts.speech.microsoft.com" - ) + "/cognitiveservices/voices/list" - headers = { - "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY - } + url = (base_url or f'https://{region}.tts.speech.microsoft.com') + '/cognitiveservices/voices/list' + headers = {'Ocp-Apim-Subscription-Key': request.app.state.config.TTS_API_KEY} - response = requests.get( - url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST - ) + response = requests.get(url, headers=headers, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) response.raise_for_status() voices = response.json() for voice in voices: - available_voices[voice["ShortName"]] = ( - f"{voice['DisplayName']} ({voice['ShortName']})" - ) + available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})' except requests.RequestException as e: - log.error(f"Error fetching voices: {str(e)}") + log.error(f'Error fetching voices: {str(e)}') return available_voices @@ -1396,10 +1318,10 @@ def get_elevenlabs_voices(api_key: str) -> dict: try: # TODO: Add retries response = requests.get( - f"{ELEVENLABS_API_BASE_URL}/v1/voices", + f'{ELEVENLABS_API_BASE_URL}/v1/voices', headers={ - "xi-api-key": api_key, - "Content-Type": "application/json", + 'xi-api-key': api_key, + 'Content-Type': 'application/json', }, timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST, ) @@ -1407,20 +1329,16 @@ def get_elevenlabs_voices(api_key: str) -> dict: voices_data = response.json() voices = {} - for voice in voices_data.get("voices", []): - voices[voice["voice_id"]] = voice["name"] + for voice in voices_data.get('voices', []): + voices[voice['voice_id']] = voice['name'] except requests.RequestException as e: # Avoid @lru_cache with exception - log.error(f"Error fetching voices: {str(e)}") - raise RuntimeError(f"Error fetching voices: {str(e)}") + log.error(f'Error fetching voices: {str(e)}') + raise RuntimeError(f'Error fetching voices: {str(e)}') return voices -@router.get("/voices") +@router.get('/voices') async def get_voices(request: Request, user=Depends(get_verified_user)): - return { - "voices": [ - {"id": k, "name": v} for k, v in get_available_voices(request).items() - ] - } + return {'voices': [{'id': k, 'name': v} for k, v in get_available_voices(request).items()]} diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 6227562b8f..94dda6dbb9 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -38,6 +38,7 @@ WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_AUTH_TRUSTED_GROUPS_HEADER, + WEBUI_AUTH_TRUSTED_ROLE_HEADER, WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_SIGNOUT_REDIRECT_URL, @@ -102,14 +103,12 @@ log = logging.getLogger(__name__) -signin_rate_limiter = RateLimiter( - redis_client=get_redis_client(), limit=5 * 3, window=60 * 3 -) +# 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) -def create_session_response( - request: Request, user, db, response: Response = None, set_cookie: bool = False -) -> dict: +def create_session_response(request: Request, user, db, response: Response = None, set_cookie: bool = False) -> dict: """ Create JWT token and build session response for a user. Shared helper for signin, signup, ldap_auth, add_user, and token_exchange endpoints. @@ -130,40 +129,36 @@ def create_session_response( expires_at = int(time.time()) + int(expires_delta.total_seconds()) token = create_token( - data={"id": user.id}, + data={'id': user.id}, expires_delta=expires_delta, ) if set_cookie and response: - datetime_expires_at = ( - datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) - if expires_at - else None - ) + datetime_expires_at = datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) if expires_at else None + max_age = int(expires_delta.total_seconds()) if expires_delta else None response.set_cookie( - key="token", + key='token', value=token, expires=datetime_expires_at, httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': max_age} if max_age is not None else {}), ) - user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS, db=db - ) + user_permissions = get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) return { - "token": token, - "token_type": "Bearer", - "expires_at": expires_at, - "id": user.id, - "email": user.email, - "name": user.name, - "role": user.role, - "profile_image_url": f"/api/v1/users/{user.id}/profile/image", - "permissions": user_permissions, - "credit": credit.credit, + 'token': token, + 'token_type': 'Bearer', + 'expires_at': expires_at, + 'id': user.id, + 'email': user.email, + 'name': user.name, + 'role': user.role, + 'profile_image_url': f'/api/v1/users/{user.id}/profile/image', + 'permissions': user_permissions, + 'credit': credit.credit, } @@ -184,14 +179,14 @@ class SessionUserInfoResponse(SessionUserResponse, UserStatus): date_of_birth: Optional[datetime.date] = None -@router.get("/", response_model=SessionUserInfoResponse) +@router.get('/', response_model=SessionUserInfoResponse) async def get_session_user( request: Request, response: Response, user: UserModel = Depends(get_current_user), db: Session = Depends(get_session), ): - auth_header = request.headers.get("Authorization") + auth_header = request.headers.get('Authorization') auth_token = get_http_authorization_cred(auth_header) token = auth_token.credentials data = decode_token(token) @@ -199,7 +194,7 @@ async def get_session_user( expires_at = None if data: - expires_at = data.get("exp") + expires_at = data.get('exp') if (expires_at is not None) and int(time.time()) > expires_at: raise HTTPException( @@ -208,42 +203,38 @@ async def get_session_user( ) # Set the cookie token + max_age = int(expires_at - time.time()) if expires_at else None response.set_cookie( - key="token", + key='token', value=token, - expires=( - datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) - if expires_at - else None - ), + expires=(datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc) if expires_at else None), httponly=True, # Ensures the cookie is not accessible via JavaScript samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': max_age} if max_age is not None else {}), ) - user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS, db=db - ) + user_permissions = get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) credit = Credits.init_credit_by_user_id(user.id) return { - "token": token, - "token_type": "Bearer", - "expires_at": expires_at, - "id": user.id, - "email": user.email, - "name": user.name, - "role": user.role, - "profile_image_url": user.profile_image_url, - "bio": user.bio, - "gender": user.gender, - "date_of_birth": user.date_of_birth, - "status_emoji": user.status_emoji, - "status_message": user.status_message, - "status_expires_at": user.status_expires_at, - "permissions": user_permissions, - "credit": credit.credit, + 'token': token, + 'token_type': 'Bearer', + 'expires_at': expires_at, + 'id': user.id, + 'email': user.email, + 'name': user.name, + 'role': user.role, + 'profile_image_url': user.profile_image_url, + 'bio': user.bio, + 'gender': user.gender, + 'date_of_birth': user.date_of_birth, + 'status_emoji': user.status_emoji, + 'status_message': user.status_message, + 'status_expires_at': user.status_expires_at, + 'permissions': user_permissions, + 'credit': credit.credit, } @@ -252,7 +243,7 @@ async def get_session_user( ############################ -@router.post("/update/profile", response_model=UserProfileImageResponse) +@router.post('/update/profile', response_model=UserProfileImageResponse) async def update_profile( form_data: UpdateProfileForm, session_user=Depends(get_verified_user), @@ -281,7 +272,7 @@ class UpdateTimezoneForm(BaseModel): timezone: str -@router.post("/update/timezone") +@router.post('/update/timezone') async def update_timezone( form_data: UpdateTimezoneForm, session_user=Depends(get_current_user), @@ -290,10 +281,10 @@ async def update_timezone( if session_user: Users.update_user_by_id( session_user.id, - {"timezone": form_data.timezone}, + {'timezone': form_data.timezone}, db=db, ) - return {"status": True} + return {'status': True} else: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) @@ -303,7 +294,7 @@ async def update_timezone( ############################ -@router.post("/update/password", response_model=bool) +@router.post('/update/password', response_model=bool) async def update_password( form_data: UpdatePasswordForm, session_user=Depends(get_current_user), @@ -320,7 +311,7 @@ async def update_password( if user: try: - validate_password(form_data.password) + validate_password(form_data.new_password) except Exception as e: raise HTTPException(400, detail=str(e)) hashed = get_password_hash(form_data.new_password) @@ -334,7 +325,7 @@ async def update_password( ############################ # LDAP Authentication ############################ -@router.post("/ldap", response_model=SessionUserResponse) +@router.post('/ldap', response_model=SessionUserResponse) async def ldap_auth( request: Request, response: Response, @@ -343,7 +334,7 @@ async def ldap_auth( ): # Security checks FIRST - before loading any config if not request.app.state.config.ENABLE_LDAP: - raise HTTPException(400, detail="LDAP authentication is not enabled") + raise HTTPException(400, detail='LDAP authentication is not enabled') if not ENABLE_PASSWORD_AUTH: raise HTTPException( @@ -363,14 +354,8 @@ async def ldap_auth( LDAP_APP_PASSWORD = request.app.state.config.LDAP_APP_PASSWORD LDAP_USE_TLS = request.app.state.config.LDAP_USE_TLS LDAP_CA_CERT_FILE = request.app.state.config.LDAP_CA_CERT_FILE - LDAP_VALIDATE_CERT = ( - CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE - ) - LDAP_CIPHERS = ( - request.app.state.config.LDAP_CIPHERS - if request.app.state.config.LDAP_CIPHERS - else "ALL" - ) + LDAP_VALIDATE_CERT = CERT_REQUIRED if request.app.state.config.LDAP_VALIDATE_CERT else CERT_NONE + LDAP_CIPHERS = request.app.state.config.LDAP_CIPHERS if request.app.state.config.LDAP_CIPHERS else 'ALL' try: tls = Tls( @@ -380,8 +365,8 @@ async def ldap_auth( ciphers=LDAP_CIPHERS, ) except Exception as e: - log.error(f"TLS configuration error: {str(e)}") - raise HTTPException(400, detail="Failed to configure TLS for LDAP connection.") + log.error(f'TLS configuration error: {str(e)}') + raise HTTPException(400, detail='Failed to configure TLS for LDAP connection.') try: server = Server( @@ -395,44 +380,38 @@ async def ldap_auth( server, LDAP_APP_DN, LDAP_APP_PASSWORD, - auto_bind="NONE", - authentication="SIMPLE" if LDAP_APP_DN else "ANONYMOUS", + auto_bind='NONE', + authentication='SIMPLE' if LDAP_APP_DN else 'ANONYMOUS', ) if not await asyncio.to_thread(connection_app.bind): - raise HTTPException(400, detail="Application account bind failed") + raise HTTPException(400, detail='Application account bind failed') - ENABLE_LDAP_GROUP_MANAGEMENT = ( - request.app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT - ) + ENABLE_LDAP_GROUP_MANAGEMENT = request.app.state.config.ENABLE_LDAP_GROUP_MANAGEMENT ENABLE_LDAP_GROUP_CREATION = request.app.state.config.ENABLE_LDAP_GROUP_CREATION LDAP_ATTRIBUTE_FOR_GROUPS = request.app.state.config.LDAP_ATTRIBUTE_FOR_GROUPS search_attributes = [ - f"{LDAP_ATTRIBUTE_FOR_USERNAME}", - f"{LDAP_ATTRIBUTE_FOR_MAIL}", - "cn", + f'{LDAP_ATTRIBUTE_FOR_USERNAME}', + f'{LDAP_ATTRIBUTE_FOR_MAIL}', + 'cn', ] if ENABLE_LDAP_GROUP_MANAGEMENT: - search_attributes.append(f"{LDAP_ATTRIBUTE_FOR_GROUPS}") - log.info( - f"LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes" - ) - log.info(f"LDAP search attributes: {search_attributes}") + search_attributes.append(f'{LDAP_ATTRIBUTE_FOR_GROUPS}') + log.info(f'LDAP Group Management enabled. Adding {LDAP_ATTRIBUTE_FOR_GROUPS} to search attributes') + log.info(f'LDAP search attributes: {search_attributes}') search_success = await asyncio.to_thread( connection_app.search, search_base=LDAP_SEARCH_BASE, - search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})", + search_filter=f'(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})', attributes=search_attributes, ) if not search_success or not connection_app.entries: - raise HTTPException(400, detail="User not found in the LDAP server") + raise HTTPException(400, detail='User not found in the LDAP server') entry = connection_app.entries[0] - entry_username = entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"].value - email = entry[ - f"{LDAP_ATTRIBUTE_FOR_MAIL}" - ].value # retrieve the Attribute value + entry_username = entry[f'{LDAP_ATTRIBUTE_FOR_USERNAME}'].value + email = entry[f'{LDAP_ATTRIBUTE_FOR_MAIL}'].value # retrieve the Attribute value username_list = [] # list of usernames from LDAP attribute if isinstance(entry_username, list): @@ -442,7 +421,7 @@ async def ldap_auth( # TODO: support multiple emails if LDAP returns a list if not email: - raise HTTPException(400, "User does not have a valid email address.") + raise HTTPException(400, 'User does not have a valid email address.') elif isinstance(email, str): email = email.lower() elif isinstance(email, list): @@ -450,47 +429,43 @@ async def ldap_auth( else: email = str(email).lower() - cn = str(entry["cn"]) # common name + cn = str(entry['cn']) # common name user_dn = entry.entry_dn # user distinguished name user_groups = [] if ENABLE_LDAP_GROUP_MANAGEMENT and LDAP_ATTRIBUTE_FOR_GROUPS in entry: group_dns = entry[LDAP_ATTRIBUTE_FOR_GROUPS] - log.info(f"LDAP raw group DNs for user {username_list}: {group_dns}") + log.info(f'LDAP raw group DNs for user {username_list}: {group_dns}') if group_dns: - log.info(f"LDAP group_dns original: {group_dns}") - log.info(f"LDAP group_dns type: {type(group_dns)}") - log.info(f"LDAP group_dns length: {len(group_dns)}") + log.info(f'LDAP group_dns original: {group_dns}') + log.info(f'LDAP group_dns type: {type(group_dns)}') + log.info(f'LDAP group_dns length: {len(group_dns)}') - if hasattr(group_dns, "value"): + if hasattr(group_dns, 'value'): group_dns = group_dns.value - log.info(f"Extracted .value property: {group_dns}") - elif hasattr(group_dns, "__iter__") and not isinstance( - group_dns, (str, bytes) - ): + log.info(f'Extracted .value property: {group_dns}') + elif hasattr(group_dns, '__iter__') and not isinstance(group_dns, (str, bytes)): group_dns = list(group_dns) - log.info(f"Converted to list: {group_dns}") + log.info(f'Converted to list: {group_dns}') if isinstance(group_dns, list): group_dns = [str(item) for item in group_dns] else: group_dns = [str(group_dns)] - log.info( - f"LDAP group_dns after processing - type: {type(group_dns)}, length: {len(group_dns)}" - ) + log.info(f'LDAP group_dns after processing - type: {type(group_dns)}, length: {len(group_dns)}') for group_idx, group_dn in enumerate(group_dns): group_dn = str(group_dn) - log.info(f"Processing group DN #{group_idx + 1}: {group_dn}") + log.info(f'Processing group DN #{group_idx + 1}: {group_dn}') try: group_cn = None - for item in group_dn.split(","): + for item in group_dn.split(','): item = item.strip() - if item.upper().startswith("CN="): + if item.upper().startswith('CN='): group_cn = item[3:] break @@ -498,22 +473,16 @@ async def ldap_auth( user_groups.append(group_cn) else: - log.warning( - f"Could not extract CN from group DN: {group_dn}" - ) + log.warning(f'Could not extract CN from group DN: {group_dn}') except Exception as e: - log.warning( - f"Failed to extract group name from DN {group_dn}: {e}" - ) + log.warning(f'Failed to extract group name from DN {group_dn}: {e}') - log.info( - f"LDAP groups for user {username_list}: {user_groups} (total: {len(user_groups)})" - ) + log.info(f'LDAP groups for user {username_list}: {user_groups} (total: {len(user_groups)})') else: - log.info(f"No groups found for user {username_list}") + log.info(f'No groups found for user {username_list}') elif ENABLE_LDAP_GROUP_MANAGEMENT: log.warning( - f"LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry" + f'LDAP Group Management enabled but {LDAP_ATTRIBUTE_FOR_GROUPS} attribute not found in user entry' ) if username_list and form_data.user.lower() in username_list: @@ -521,20 +490,16 @@ async def ldap_auth( server, user_dn, form_data.password, - auto_bind="NONE", - authentication="SIMPLE", + auto_bind='NONE', + authentication='SIMPLE', ) if not await asyncio.to_thread(connection_user.bind): - raise HTTPException(400, "Authentication failed.") + raise HTTPException(400, 'Authentication failed.') user = Users.get_user_by_email(email, db=db) if not user: try: - role = ( - "admin" - if not Users.has_users(db=db) - else request.app.state.config.DEFAULT_USER_ROLE - ) + role = 'admin' if not Users.has_users(db=db) else request.app.state.config.DEFAULT_USER_ROLE user = Auths.insert_new_auth( email=email, @@ -545,9 +510,7 @@ async def ldap_auth( ) if not user: - raise HTTPException( - 500, detail=ERROR_MESSAGES.CREATE_USER_ERROR - ) + raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) apply_default_group_assignment( request.app.state.config.DEFAULT_GROUP_ID, @@ -558,39 +521,29 @@ async def ldap_auth( except HTTPException: raise except Exception as err: - log.error(f"LDAP user creation error: {str(err)}") - raise HTTPException( - 500, detail="Internal error occurred during LDAP user creation." - ) + log.error(f'LDAP user creation error: {str(err)}') + raise HTTPException(500, detail='Internal error occurred during LDAP user creation.') user = Auths.authenticate_user_by_email(email, db=db) if user: - if ( - user.role != "admin" - and ENABLE_LDAP_GROUP_MANAGEMENT - and user_groups - ): + if ENABLE_LDAP_GROUP_MANAGEMENT and user_groups: if ENABLE_LDAP_GROUP_CREATION: Groups.create_groups_by_group_names(user.id, user_groups, db=db) try: Groups.sync_groups_by_group_names(user.id, user_groups, db=db) - log.info( - f"Successfully synced groups for user {user.id}: {user_groups}" - ) + log.info(f'Successfully synced groups for user {user.id}: {user_groups}') except Exception as e: - log.error(f"Failed to sync groups for user {user.id}: {e}") + log.error(f'Failed to sync groups for user {user.id}: {e}') - return create_session_response( - request, user, db, response, set_cookie=True - ) + return create_session_response(request, user, db, response, set_cookie=True) else: raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) else: - raise HTTPException(400, "User record mismatch.") + raise HTTPException(400, 'User record mismatch.') except Exception as e: - log.error(f"LDAP authentication error: {str(e)}") - raise HTTPException(400, detail="LDAP authentication failed.") + log.error(f'LDAP authentication error: {str(e)}') + raise HTTPException(400, detail='LDAP authentication failed.') ############################ @@ -598,7 +551,7 @@ async def ldap_auth( ############################ -@router.post("/signin", response_model=SessionUserResponse) +@router.post('/signin', response_model=SessionUserResponse) async def signin( request: Request, response: Response, @@ -621,7 +574,7 @@ async def signin( if WEBUI_AUTH_TRUSTED_NAME_HEADER: name = request.headers.get(WEBUI_AUTH_TRUSTED_NAME_HEADER, email) try: - name = urllib.parse.unquote(name, encoding="utf-8") + name = urllib.parse.unquote(name, encoding='utf-8') except Exception as e: pass @@ -635,18 +588,25 @@ async def signin( ) user = Auths.authenticate_user_by_email(email, db=db) - if WEBUI_AUTH_TRUSTED_GROUPS_HEADER and user and user.role != "admin": - group_names = request.headers.get( - WEBUI_AUTH_TRUSTED_GROUPS_HEADER, "" - ).split(",") - group_names = [name.strip() for name in group_names if name.strip()] + if user: + if WEBUI_AUTH_TRUSTED_GROUPS_HEADER: + group_names = request.headers.get(WEBUI_AUTH_TRUSTED_GROUPS_HEADER, '').split(',') + group_names = [name.strip() for name in group_names if name.strip()] - if group_names: - Groups.sync_groups_by_group_names(user.id, group_names, db=db) + if group_names: + Groups.sync_groups_by_group_names(user.id, group_names, db=db) + + if WEBUI_AUTH_TRUSTED_ROLE_HEADER: + trusted_role = request.headers.get(WEBUI_AUTH_TRUSTED_ROLE_HEADER, '').lower().strip() + if trusted_role in {'admin', 'user', 'pending'}: + if user.role != trusted_role: + Users.update_user_role_by_id(user.id, trusted_role, db=db) + elif trusted_role: + log.warning(f'Ignoring invalid trusted role header value: {trusted_role}') elif WEBUI_AUTH == False: - admin_email = "admin@localhost" - admin_password = "admin" + admin_email = 'admin@localhost' + admin_password = 'admin' if Users.get_user_by_email(admin_email.lower(), db=db): user = Auths.authenticate_user( @@ -662,7 +622,7 @@ async def signin( request, admin_email, admin_password, - "User", + 'User', db=db, ) @@ -678,14 +638,14 @@ async def signin( detail=ERROR_MESSAGES.RATE_LIMIT_EXCEEDED, ) - password_bytes = form_data.password.encode("utf-8") + password_bytes = form_data.password.encode('utf-8') if len(password_bytes) > 72: # TODO: Implement other hashing algorithms that support longer passwords - log.info("Password too long, truncating to 72 bytes for bcrypt") + log.info('Password too long, truncating to 72 bytes for bcrypt') password_bytes = password_bytes[:72] # decode safely โ€” ignore incomplete UTF-8 sequences - form_data.password = password_bytes.decode("utf-8", errors="ignore") + form_data.password = password_bytes.decode('utf-8', errors='ignore') user = Auths.authenticate_user( form_data.email.lower(), @@ -709,7 +669,7 @@ async def signup_handler( email: str, password: str, name: str, - profile_image_url: str = "/user.png", + profile_image_url: str = '/user.png', *, db: Session, ) -> UserModel: @@ -726,9 +686,9 @@ async def signup_handler( has_users = Users.has_users(db=db) if not has_users: - role = "admin" + role = 'admin' elif request.app.state.config.ENABLE_SIGNUP_VERIFY: - role = "pending" + role = 'pending' send_verify_email(email=email.lower()) else: role = request.app.state.config.DEFAULT_USER_ROLE @@ -749,7 +709,7 @@ async def signup_handler( # Atomically check if this is the only user *after* the insert. # Only the single user present at this point should become admin. if Users.get_num_users(db=db) == 1: - Users.update_user_role_by_id(user.id, "admin", db=db) + Users.update_user_role_by_id(user.id, 'admin', db=db) user = Users.get_user_by_id(user.id, db=db) request.app.state.config.ENABLE_SIGNUP = False @@ -759,9 +719,9 @@ async def signup_handler( request.app.state.config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), { - "action": "signup", - "message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name), - "user": user.model_dump_json(exclude_none=True), + 'action': 'signup', + 'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name), + 'user': user.model_dump_json(exclude_none=True), }, ) @@ -774,7 +734,7 @@ async def signup_handler( return user -@router.post("/signup", response_model=SessionUserResponse) +@router.post('/signup', response_model=SessionUserResponse) async def signup( request: Request, response: Response, @@ -784,38 +744,25 @@ async def signup( has_users = Users.has_users(db=db) if WEBUI_AUTH: - if ( - not request.app.state.config.ENABLE_SIGNUP - or not request.app.state.config.ENABLE_LOGIN_FORM - ): + if not request.app.state.config.ENABLE_SIGNUP or not request.app.state.config.ENABLE_LOGIN_FORM: if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP: - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED - ) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) else: if has_users: - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED - ) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) # check for email domain whitelist - email_domain_whitelist = [ - i.strip() - for i in request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST.split(",") - if i - ] + email_domain_whitelist = [i.strip() for i in request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST.split(',') if i] if email_domain_whitelist: - domain = form_data.email.split("@")[-1] + domain = form_data.email.split('@')[-1] if domain not in email_domain_whitelist: raise HTTPException( status.HTTP_403_FORBIDDEN, - detail=f"Only emails from {request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST} are allowed", + detail=f'Only emails from {request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST} are allowed', ) if not validate_email_format(form_data.email.lower()): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT) if Users.get_user_by_email(form_data.email.lower(), db=db): raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) @@ -838,47 +785,45 @@ async def signup( except HTTPException: raise except Exception as err: - log.error(f"Signup error: {str(err)}") - raise HTTPException(500, detail="An internal error occurred during signup.") + log.error(f'Signup error: {str(err)}') + raise HTTPException(500, detail='An internal error occurred during signup.') -@router.get("/signup_verify/{code}") +@router.get('/signup_verify/{code}') async def signup_verify(request: Request, code: str): email = verify_email_by_code(code=code) if not email: - raise HTTPException(403, detail="Invalid code") + raise HTTPException(403, detail='Invalid code') user = Users.get_user_by_email(email) if not user: - raise HTTPException(404, detail="User not found") + raise HTTPException(404, detail='User not found') - Users.update_user_role_by_id(user.id, "user") + Users.update_user_role_by_id(user.id, 'user') return RedirectResponse(url=request.app.state.config.WEBUI_URL) -@router.get("/signout") -async def signout( - request: Request, response: Response, db: Session = Depends(get_session) -): +@router.get('/signout') +async def signout(request: Request, response: Response, db: Session = Depends(get_session)): # get auth token from headers or cookies token = None - auth_header = request.headers.get("Authorization") + auth_header = request.headers.get('Authorization') if auth_header: auth_cred = get_http_authorization_cred(auth_header) token = auth_cred.credentials else: - token = request.cookies.get("token") + token = request.cookies.get('token') if token: await invalidate_token(request, token) - response.delete_cookie("token") - response.delete_cookie("oui-session") - response.delete_cookie("oauth_id_token") + response.delete_cookie('token') + response.delete_cookie('oui-session') + response.delete_cookie('oauth_id_token') - oauth_session_id = request.cookies.get("oauth_session_id") + oauth_session_id = request.cookies.get('oauth_session_id') if oauth_session_id: - response.delete_cookie("oauth_session_id") + response.delete_cookie('oauth_session_id') session = OAuthSessions.get_session_by_id(oauth_session_id, db=db) @@ -888,49 +833,47 @@ async def signout( return JSONResponse( status_code=200, content={ - "status": True, - "redirect_url": OPENID_END_SESSION_ENDPOINT.value, + 'status': True, + 'redirect_url': OPENID_END_SESSION_ENDPOINT.value, }, headers=response.headers, ) oauth_server_metadata_url = ( - request.app.state.oauth_manager.get_server_metadata_url(session.provider) - if session - else None + request.app.state.oauth_manager.get_server_metadata_url(session.provider) if session else None ) or OPENID_PROVIDER_URL.value if session and oauth_server_metadata_url: - oauth_id_token = session.token.get("id_token") + oauth_id_token = session.token.get('id_token') try: async with ClientSession(trust_env=True) as session: async with session.get(oauth_server_metadata_url) as r: if r.status == 200: openid_data = await r.json() - logout_url = openid_data.get("end_session_endpoint") + logout_url = openid_data.get('end_session_endpoint') if logout_url: return JSONResponse( status_code=200, content={ - "status": True, - "redirect_url": f"{logout_url}?id_token_hint={oauth_id_token}" + 'status': True, + 'redirect_url': f'{logout_url}?id_token_hint={oauth_id_token}' + ( - f"&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}" + f'&post_logout_redirect_uri={WEBUI_AUTH_SIGNOUT_REDIRECT_URL}' if WEBUI_AUTH_SIGNOUT_REDIRECT_URL - else "" + else '' ), }, headers=response.headers, ) else: - raise Exception("Failed to fetch OpenID configuration") + raise Exception('Failed to fetch OpenID configuration') except Exception as e: - log.error(f"OpenID signout error: {str(e)}") + log.error(f'OpenID signout error: {str(e)}') raise HTTPException( status_code=500, - detail="Failed to sign out from the OpenID provider.", + detail='Failed to sign out from the OpenID provider.', headers=response.headers, ) @@ -938,15 +881,13 @@ async def signout( return JSONResponse( status_code=200, content={ - "status": True, - "redirect_url": WEBUI_AUTH_SIGNOUT_REDIRECT_URL, + 'status': True, + 'redirect_url': WEBUI_AUTH_SIGNOUT_REDIRECT_URL, }, headers=response.headers, ) - return JSONResponse( - status_code=200, content={"status": True}, headers=response.headers - ) + return JSONResponse(status_code=200, content={'status': True}, headers=response.headers) ############################ @@ -954,7 +895,7 @@ async def signout( ############################ -@router.post("/add", response_model=SigninResponse) +@router.post('/add', response_model=SigninResponse) async def add_user( request: Request, form_data: AddUserForm, @@ -962,9 +903,7 @@ async def add_user( db: Session = Depends(get_session), ): if not validate_email_format(form_data.email.lower()): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT - ) + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT) if Users.get_user_by_email(form_data.email.lower(), db=db): raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) @@ -992,25 +931,23 @@ async def add_user( db=db, ) - token = create_token(data={"id": user.id}) + token = create_token(data={'id': user.id}) return { - "token": token, - "token_type": "Bearer", - "id": user.id, - "email": user.email, - "name": user.name, - "role": user.role, - "profile_image_url": f"/api/v1/users/{user.id}/profile/image", + 'token': token, + 'token_type': 'Bearer', + 'id': user.id, + 'email': user.email, + 'name': user.name, + 'role': user.role, + 'profile_image_url': f'/api/v1/users/{user.id}/profile/image', } else: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_USER_ERROR) except HTTPException: raise except Exception as err: - log.error(f"Add user error: {str(err)}") - raise HTTPException( - 500, detail="An internal error occurred while adding the user." - ) + log.error(f'Add user error: {str(err)}') + raise HTTPException(500, detail='An internal error occurred while adding the user.') ############################ @@ -1018,15 +955,13 @@ async def add_user( ############################ -@router.get("/admin/details") -async def get_admin_details( - request: Request, user=Depends(get_current_user), db: Session = Depends(get_session) -): +@router.get('/admin/details') +async def get_admin_details(request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)): if request.app.state.config.SHOW_ADMIN_DETAILS: admin_email = request.app.state.config.ADMIN_EMAIL admin_name = None - log.info(f"Admin details - Email: {admin_email}, Name: {admin_name}") + log.info(f'Admin details - Email: {admin_email}, Name: {admin_name}') if admin_email: admin = Users.get_user_by_email(admin_email, db=db) @@ -1039,8 +974,8 @@ async def get_admin_details( admin_name = admin.name return { - "name": admin_name, - "email": admin_email, + 'name': admin_name, + 'email': admin_email, } else: raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED) @@ -1051,38 +986,38 @@ async def get_admin_details( ############################ -@router.get("/admin/config") +@router.get('/admin/config') async def get_admin_config(request: Request, user=Depends(get_admin_user)): return { - "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, - "ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL, - "WEBUI_URL": request.app.state.config.WEBUI_URL, - "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, - "ENABLE_SIGNUP_VERIFY": request.app.state.config.ENABLE_SIGNUP_VERIFY, - "SIGNUP_EMAIL_DOMAIN_WHITELIST": request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST, - "SMTP_HOST": request.app.state.config.SMTP_HOST, - "SMTP_PORT": request.app.state.config.SMTP_PORT, - "SMTP_USERNAME": request.app.state.config.SMTP_USERNAME, - "SMTP_PASSWORD": request.app.state.config.SMTP_PASSWORD, - "SMTP_SENT_FROM": request.app.state.config.SMTP_SENT_FROM, - "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS, - "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, - "API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS, - "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, - "DEFAULT_GROUP_ID": request.app.state.config.DEFAULT_GROUP_ID, - "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, - "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, - "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, - "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS, - "FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT, - "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, - "ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES, - "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, - "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, - "ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS, - "PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE, - "PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT, - "RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK, + 'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS, + 'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL, + 'WEBUI_URL': request.app.state.config.WEBUI_URL, + 'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP, + 'ENABLE_SIGNUP_VERIFY': request.app.state.config.ENABLE_SIGNUP_VERIFY, + 'SIGNUP_EMAIL_DOMAIN_WHITELIST': request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST, + 'SMTP_HOST': request.app.state.config.SMTP_HOST, + 'SMTP_PORT': request.app.state.config.SMTP_PORT, + 'SMTP_USERNAME': request.app.state.config.SMTP_USERNAME, + 'SMTP_PASSWORD': request.app.state.config.SMTP_PASSWORD, + 'SMTP_SENT_FROM': request.app.state.config.SMTP_SENT_FROM, + 'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS, + 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, + 'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS, + 'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE, + 'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID, + 'JWT_EXPIRES_IN': request.app.state.config.JWT_EXPIRES_IN, + 'ENABLE_COMMUNITY_SHARING': request.app.state.config.ENABLE_COMMUNITY_SHARING, + 'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING, + 'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS, + 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, + 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, + 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, + 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, + 'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS, + 'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS, + 'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE, + 'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT, + 'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK, } @@ -1092,7 +1027,7 @@ class AdminConfig(BaseModel): WEBUI_URL: str ENABLE_SIGNUP: bool ENABLE_SIGNUP_VERIFY: bool = Field(default=False) - SIGNUP_EMAIL_DOMAIN_WHITELIST: str = Field(default="") + SIGNUP_EMAIL_DOMAIN_WHITELIST: str = Field(default='') SMTP_HOST: str SMTP_PORT: str SMTP_USERNAME: str @@ -1118,31 +1053,25 @@ class AdminConfig(BaseModel): RESPONSE_WATERMARK: Optional[str] = None -@router.post("/admin/config") -async def update_admin_config( - request: Request, form_data: AdminConfig, user=Depends(get_admin_user) -): +@router.post('/admin/config') +async def update_admin_config(request: Request, form_data: AdminConfig, user=Depends(get_admin_user)): # verify redis status if form_data.ENABLE_SIGNUP_VERIFY: # check redis _redis = get_redis_connection( redis_url=REDIS_URL, - redis_sentinels=get_sentinels_from_env( - REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT - ), + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), redis_cluster=REDIS_CLUSTER, ) if not _redis: - raise HTTPException(status_code=400, detail="Redis is not configured.") + raise HTTPException(status_code=400, detail='Redis is not configured.') request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS request.app.state.config.ADMIN_EMAIL = form_data.ADMIN_EMAIL request.app.state.config.WEBUI_URL = form_data.WEBUI_URL request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP request.app.state.config.ENABLE_SIGNUP_VERIFY = form_data.ENABLE_SIGNUP_VERIFY - request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST = ( - form_data.SIGNUP_EMAIL_DOMAIN_WHITELIST - ) + request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST = form_data.SIGNUP_EMAIL_DOMAIN_WHITELIST request.app.state.config.SMTP_HOST = form_data.SMTP_HOST request.app.state.config.SMTP_PORT = form_data.SMTP_PORT request.app.state.config.SMTP_USERNAME = form_data.SMTP_USERNAME @@ -1150,79 +1079,69 @@ async def update_admin_config( request.app.state.config.SMTP_SENT_FROM = form_data.SMTP_SENT_FROM request.app.state.config.ENABLE_API_KEYS = form_data.ENABLE_API_KEYS - request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = ( - form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS - ) - request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = ( - form_data.API_KEYS_ALLOWED_ENDPOINTS - ) + request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS = form_data.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS + request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS = form_data.API_KEYS_ALLOWED_ENDPOINTS request.app.state.config.ENABLE_FOLDERS = form_data.ENABLE_FOLDERS request.app.state.config.FOLDER_MAX_FILE_COUNT = ( - int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else "" + int(form_data.FOLDER_MAX_FILE_COUNT) if form_data.FOLDER_MAX_FILE_COUNT else '' ) request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES - if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]: + if form_data.DEFAULT_USER_ROLE in ['pending', 'user', 'admin']: request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE request.app.state.config.DEFAULT_GROUP_ID = form_data.DEFAULT_GROUP_ID - pattern = r"^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$" + pattern = r'^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$' # Check if the input string matches the pattern if re.match(pattern, form_data.JWT_EXPIRES_IN): request.app.state.config.JWT_EXPIRES_IN = form_data.JWT_EXPIRES_IN - request.app.state.config.ENABLE_COMMUNITY_SHARING = ( - form_data.ENABLE_COMMUNITY_SHARING - ) + request.app.state.config.ENABLE_COMMUNITY_SHARING = form_data.ENABLE_COMMUNITY_SHARING request.app.state.config.ENABLE_MESSAGE_RATING = form_data.ENABLE_MESSAGE_RATING request.app.state.config.ENABLE_USER_WEBHOOKS = form_data.ENABLE_USER_WEBHOOKS request.app.state.config.ENABLE_USER_STATUS = form_data.ENABLE_USER_STATUS - request.app.state.config.PENDING_USER_OVERLAY_TITLE = ( - form_data.PENDING_USER_OVERLAY_TITLE - ) - request.app.state.config.PENDING_USER_OVERLAY_CONTENT = ( - form_data.PENDING_USER_OVERLAY_CONTENT - ) + request.app.state.config.PENDING_USER_OVERLAY_TITLE = form_data.PENDING_USER_OVERLAY_TITLE + request.app.state.config.PENDING_USER_OVERLAY_CONTENT = form_data.PENDING_USER_OVERLAY_CONTENT request.app.state.config.RESPONSE_WATERMARK = form_data.RESPONSE_WATERMARK return { - "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, - "ADMIN_EMAIL": request.app.state.config.ADMIN_EMAIL, - "WEBUI_URL": request.app.state.config.WEBUI_URL, - "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, - "ENABLE_SIGNUP_VERIFY": request.app.state.config.ENABLE_SIGNUP_VERIFY, - "SIGNUP_EMAIL_DOMAIN_WHITELIST": request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST, - "SMTP_HOST": request.app.state.config.SMTP_HOST, - "SMTP_PORT": request.app.state.config.SMTP_PORT, - "SMTP_USERNAME": request.app.state.config.SMTP_USERNAME, - "SMTP_PASSWORD": request.app.state.config.SMTP_PASSWORD, - "SMTP_SENT_FROM": request.app.state.config.SMTP_SENT_FROM, - "ENABLE_API_KEYS": request.app.state.config.ENABLE_API_KEYS, - "ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, - "API_KEYS_ALLOWED_ENDPOINTS": request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS, - "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, - "DEFAULT_GROUP_ID": request.app.state.config.DEFAULT_GROUP_ID, - "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, - "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, - "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING, - "ENABLE_FOLDERS": request.app.state.config.ENABLE_FOLDERS, - "FOLDER_MAX_FILE_COUNT": request.app.state.config.FOLDER_MAX_FILE_COUNT, - "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS, - "ENABLE_MEMORIES": request.app.state.config.ENABLE_MEMORIES, - "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES, - "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS, - "ENABLE_USER_STATUS": request.app.state.config.ENABLE_USER_STATUS, - "PENDING_USER_OVERLAY_TITLE": request.app.state.config.PENDING_USER_OVERLAY_TITLE, - "PENDING_USER_OVERLAY_CONTENT": request.app.state.config.PENDING_USER_OVERLAY_CONTENT, - "RESPONSE_WATERMARK": request.app.state.config.RESPONSE_WATERMARK, + 'SHOW_ADMIN_DETAILS': request.app.state.config.SHOW_ADMIN_DETAILS, + 'ADMIN_EMAIL': request.app.state.config.ADMIN_EMAIL, + 'WEBUI_URL': request.app.state.config.WEBUI_URL, + 'ENABLE_SIGNUP': request.app.state.config.ENABLE_SIGNUP, + 'ENABLE_SIGNUP_VERIFY': request.app.state.config.ENABLE_SIGNUP_VERIFY, + 'SIGNUP_EMAIL_DOMAIN_WHITELIST': request.app.state.config.SIGNUP_EMAIL_DOMAIN_WHITELIST, + 'SMTP_HOST': request.app.state.config.SMTP_HOST, + 'SMTP_PORT': request.app.state.config.SMTP_PORT, + 'SMTP_USERNAME': request.app.state.config.SMTP_USERNAME, + 'SMTP_PASSWORD': request.app.state.config.SMTP_PASSWORD, + 'SMTP_SENT_FROM': request.app.state.config.SMTP_SENT_FROM, + 'ENABLE_API_KEYS': request.app.state.config.ENABLE_API_KEYS, + 'ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS': request.app.state.config.ENABLE_API_KEYS_ENDPOINT_RESTRICTIONS, + 'API_KEYS_ALLOWED_ENDPOINTS': request.app.state.config.API_KEYS_ALLOWED_ENDPOINTS, + 'DEFAULT_USER_ROLE': request.app.state.config.DEFAULT_USER_ROLE, + 'DEFAULT_GROUP_ID': request.app.state.config.DEFAULT_GROUP_ID, + 'JWT_EXPIRES_IN': request.app.state.config.JWT_EXPIRES_IN, + 'ENABLE_COMMUNITY_SHARING': request.app.state.config.ENABLE_COMMUNITY_SHARING, + 'ENABLE_MESSAGE_RATING': request.app.state.config.ENABLE_MESSAGE_RATING, + 'ENABLE_FOLDERS': request.app.state.config.ENABLE_FOLDERS, + 'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT, + 'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS, + 'ENABLE_MEMORIES': request.app.state.config.ENABLE_MEMORIES, + 'ENABLE_NOTES': request.app.state.config.ENABLE_NOTES, + 'ENABLE_USER_WEBHOOKS': request.app.state.config.ENABLE_USER_WEBHOOKS, + 'ENABLE_USER_STATUS': request.app.state.config.ENABLE_USER_STATUS, + 'PENDING_USER_OVERLAY_TITLE': request.app.state.config.PENDING_USER_OVERLAY_TITLE, + 'PENDING_USER_OVERLAY_CONTENT': request.app.state.config.PENDING_USER_OVERLAY_CONTENT, + 'RESPONSE_WATERMARK': request.app.state.config.RESPONSE_WATERMARK, } @@ -1230,62 +1149,58 @@ class LdapServerConfig(BaseModel): label: str host: str port: Optional[int] = None - attribute_for_mail: str = "mail" - attribute_for_username: str = "uid" + attribute_for_mail: str = 'mail' + attribute_for_username: str = 'uid' app_dn: str app_dn_password: str search_base: str - search_filters: str = "" + search_filters: str = '' use_tls: bool = True certificate_path: Optional[str] = None validate_cert: bool = True - ciphers: Optional[str] = "ALL" + ciphers: Optional[str] = 'ALL' -@router.get("/admin/config/ldap/server", response_model=LdapServerConfig) +@router.get('/admin/config/ldap/server', response_model=LdapServerConfig) async def get_ldap_server(request: Request, user=Depends(get_admin_user)): return { - "label": request.app.state.config.LDAP_SERVER_LABEL, - "host": request.app.state.config.LDAP_SERVER_HOST, - "port": request.app.state.config.LDAP_SERVER_PORT, - "attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL, - "attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME, - "app_dn": request.app.state.config.LDAP_APP_DN, - "app_dn_password": request.app.state.config.LDAP_APP_PASSWORD, - "search_base": request.app.state.config.LDAP_SEARCH_BASE, - "search_filters": request.app.state.config.LDAP_SEARCH_FILTERS, - "use_tls": request.app.state.config.LDAP_USE_TLS, - "certificate_path": request.app.state.config.LDAP_CA_CERT_FILE, - "validate_cert": request.app.state.config.LDAP_VALIDATE_CERT, - "ciphers": request.app.state.config.LDAP_CIPHERS, + 'label': request.app.state.config.LDAP_SERVER_LABEL, + 'host': request.app.state.config.LDAP_SERVER_HOST, + 'port': request.app.state.config.LDAP_SERVER_PORT, + 'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL, + 'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME, + 'app_dn': request.app.state.config.LDAP_APP_DN, + 'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD, + 'search_base': request.app.state.config.LDAP_SEARCH_BASE, + 'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS, + 'use_tls': request.app.state.config.LDAP_USE_TLS, + 'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE, + 'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT, + 'ciphers': request.app.state.config.LDAP_CIPHERS, } -@router.post("/admin/config/ldap/server") -async def update_ldap_server( - request: Request, form_data: LdapServerConfig, user=Depends(get_admin_user) -): +@router.post('/admin/config/ldap/server') +async def update_ldap_server(request: Request, form_data: LdapServerConfig, user=Depends(get_admin_user)): required_fields = [ - "label", - "host", - "attribute_for_mail", - "attribute_for_username", - "search_base", + 'label', + 'host', + 'attribute_for_mail', + 'attribute_for_username', + 'search_base', ] for key in required_fields: value = getattr(form_data, key) if not value: - raise HTTPException(400, detail=f"Required field {key} is empty") + raise HTTPException(400, detail=f'Required field {key} is empty') request.app.state.config.LDAP_SERVER_LABEL = form_data.label request.app.state.config.LDAP_SERVER_HOST = form_data.host request.app.state.config.LDAP_SERVER_PORT = form_data.port request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail - request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = ( - form_data.attribute_for_username - ) - request.app.state.config.LDAP_APP_DN = form_data.app_dn or "" - request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or "" + request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = form_data.attribute_for_username + request.app.state.config.LDAP_APP_DN = form_data.app_dn or '' + request.app.state.config.LDAP_APP_PASSWORD = form_data.app_dn_password or '' request.app.state.config.LDAP_SEARCH_BASE = form_data.search_base request.app.state.config.LDAP_SEARCH_FILTERS = form_data.search_filters request.app.state.config.LDAP_USE_TLS = form_data.use_tls @@ -1294,37 +1209,35 @@ async def update_ldap_server( request.app.state.config.LDAP_CIPHERS = form_data.ciphers return { - "label": request.app.state.config.LDAP_SERVER_LABEL, - "host": request.app.state.config.LDAP_SERVER_HOST, - "port": request.app.state.config.LDAP_SERVER_PORT, - "attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL, - "attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME, - "app_dn": request.app.state.config.LDAP_APP_DN, - "app_dn_password": request.app.state.config.LDAP_APP_PASSWORD, - "search_base": request.app.state.config.LDAP_SEARCH_BASE, - "search_filters": request.app.state.config.LDAP_SEARCH_FILTERS, - "use_tls": request.app.state.config.LDAP_USE_TLS, - "certificate_path": request.app.state.config.LDAP_CA_CERT_FILE, - "validate_cert": request.app.state.config.LDAP_VALIDATE_CERT, - "ciphers": request.app.state.config.LDAP_CIPHERS, + 'label': request.app.state.config.LDAP_SERVER_LABEL, + 'host': request.app.state.config.LDAP_SERVER_HOST, + 'port': request.app.state.config.LDAP_SERVER_PORT, + 'attribute_for_mail': request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL, + 'attribute_for_username': request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME, + 'app_dn': request.app.state.config.LDAP_APP_DN, + 'app_dn_password': request.app.state.config.LDAP_APP_PASSWORD, + 'search_base': request.app.state.config.LDAP_SEARCH_BASE, + 'search_filters': request.app.state.config.LDAP_SEARCH_FILTERS, + 'use_tls': request.app.state.config.LDAP_USE_TLS, + 'certificate_path': request.app.state.config.LDAP_CA_CERT_FILE, + 'validate_cert': request.app.state.config.LDAP_VALIDATE_CERT, + 'ciphers': request.app.state.config.LDAP_CIPHERS, } -@router.get("/admin/config/ldap") +@router.get('/admin/config/ldap') async def get_ldap_config(request: Request, user=Depends(get_admin_user)): - return {"ENABLE_LDAP": request.app.state.config.ENABLE_LDAP} + return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP} class LdapConfigForm(BaseModel): enable_ldap: Optional[bool] = None -@router.post("/admin/config/ldap") -async def update_ldap_config( - request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user) -): +@router.post('/admin/config/ldap') +async def update_ldap_config(request: Request, form_data: LdapConfigForm, user=Depends(get_admin_user)): request.app.state.config.ENABLE_LDAP = form_data.enable_ldap - return {"ENABLE_LDAP": request.app.state.config.ENABLE_LDAP} + return {'ENABLE_LDAP': request.app.state.config.ENABLE_LDAP} ############################ @@ -1333,12 +1246,11 @@ async def update_ldap_config( # create api key -@router.post("/api_key", response_model=ApiKey) -async def generate_api_key( - request: Request, user=Depends(get_current_user), db: Session = Depends(get_session) -): - if not request.app.state.config.ENABLE_API_KEYS or not has_permission( - user.id, "features.api_keys", request.app.state.config.USER_PERMISSIONS +@router.post('/api_key', response_model=ApiKey) +async def generate_api_key(request: Request, user=Depends(get_current_user), db: Session = Depends(get_session)): + if not request.app.state.config.ENABLE_API_KEYS or ( + user.role != 'admin' + and not has_permission(user.id, 'features.api_keys', request.app.state.config.USER_PERMISSIONS) ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -1350,29 +1262,25 @@ async def generate_api_key( if success: return { - "api_key": api_key, + 'api_key': api_key, } else: raise HTTPException(500, detail=ERROR_MESSAGES.CREATE_API_KEY_ERROR) # delete api key -@router.delete("/api_key", response_model=bool) -async def delete_api_key( - user=Depends(get_current_user), db: Session = Depends(get_session) -): +@router.delete('/api_key', response_model=bool) +async def delete_api_key(user=Depends(get_current_user), db: Session = Depends(get_session)): return Users.delete_user_api_key_by_id(user.id, db=db) # get api key -@router.get("/api_key", response_model=ApiKey) -async def get_api_key( - user=Depends(get_current_user), db: Session = Depends(get_session) -): +@router.get('/api_key', response_model=ApiKey) +async def get_api_key(user=Depends(get_current_user), db: Session = Depends(get_session)): api_key = Users.get_user_api_key_by_id(user.id, db=db) if api_key: return { - "api_key": api_key, + 'api_key': api_key, } else: raise HTTPException(404, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND) @@ -1387,7 +1295,7 @@ class TokenExchangeForm(BaseModel): token: str # OAuth access token from external provider -@router.post("/oauth/{provider}/token/exchange", response_model=SessionUserResponse) +@router.post('/oauth/{provider}/token/exchange', response_model=SessionUserResponse) async def token_exchange( request: Request, response: Response, @@ -1402,7 +1310,7 @@ async def token_exchange( if not ENABLE_OAUTH_TOKEN_EXCHANGE: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Token exchange is disabled", + detail='Token exchange is disabled', ) provider = provider.lower() @@ -1424,19 +1332,19 @@ async def token_exchange( # Validate the token by calling the userinfo endpoint try: - token_data = {"access_token": form_data.token, "token_type": "Bearer"} + token_data = {'access_token': form_data.token, 'token_type': 'Bearer'} user_data = await client.userinfo(token=token_data) if not user_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid token or unable to fetch user info", + detail='Invalid token or unable to fetch user info', ) except Exception as e: - log.warning(f"Token exchange failed for provider {provider}: {e}") + log.warning(f'Token exchange failed for provider {provider}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid token or unable to validate with provider", + detail='Invalid token or unable to validate with provider', ) # Extract user information from the token claims @@ -1444,23 +1352,20 @@ async def token_exchange( username_claim = request.app.state.config.OAUTH_USERNAME_CLAIM # Get sub claim - sub = user_data.get( - request.app.state.config.OAUTH_SUB_CLAIM - or OAUTH_PROVIDERS[provider].get("sub_claim", "sub") - ) + sub = user_data.get(request.app.state.config.OAUTH_SUB_CLAIM or OAUTH_PROVIDERS[provider].get('sub_claim', 'sub')) if not sub: - log.warning(f"Token exchange failed: sub claim missing from user data") + log.warning(f'Token exchange failed: sub claim missing from user data') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token missing required 'sub' claim", ) - email = user_data.get(email_claim, "") + email = user_data.get(email_claim, '') if not email: - log.warning(f"Token exchange failed: email claim missing from user data") + log.warning(f'Token exchange failed: email claim missing from user data') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token missing required email claim", + detail='Token missing required email claim', ) email = email.lower() @@ -1477,7 +1382,7 @@ async def token_exchange( if not user: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="User not found. Please sign in via the web interface first.", + detail='User not found. Please sign in via the web interface first.', ) return create_session_response(request, user, db) diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index 55a6e6ebba..68ea5ff7f8 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -36,7 +36,7 @@ ChannelWebhookModel, ChannelWebhookForm, ) -from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant +from open_webui.models.access_grants import AccessGrants, has_public_read_access_grant, has_public_write_access_grant from open_webui.models.messages import ( Messages, MessageModel, @@ -75,34 +75,28 @@ def channel_has_access( user_id: str, channel: ChannelModel, - permission: str = "read", + permission: str = 'read', strict: bool = True, db: Optional[Session] = None, ) -> bool: if AccessGrants.has_access( user_id=user_id, - resource_type="channel", + resource_type='channel', resource_id=channel.id, permission=permission, db=db, ): return True - if ( - not strict - and permission == "write" - and has_public_read_access_grant(channel.access_grants) - ): + if not strict and permission == 'write' and has_public_write_access_grant(channel.access_grants): return True return False -def get_channel_users_with_access( - channel: ChannelModel, permission: str = "read", db: Optional[Session] = None -): +def get_channel_users_with_access(channel: ChannelModel, permission: str = 'read', db: Optional[Session] = None): return AccessGrants.get_users_with_access( - resource_type="channel", + resource_type='channel', resource_id=channel.id, permission=permission, db=db, @@ -110,9 +104,9 @@ def get_channel_users_with_access( def get_channel_permitted_group_and_user_ids( - channel: ChannelModel, permission: str = "read" + channel: ChannelModel, permission: str = 'read' ) -> Optional[dict[str, list[str]]]: - if permission == "read" and has_public_read_access_grant(channel.access_grants): + if permission == 'read' and has_public_read_access_grant(channel.access_grants): return None user_ids = [] @@ -121,19 +115,21 @@ def get_channel_permitted_group_and_user_ids( for grant in channel.access_grants: if grant.permission != permission: continue - if grant.principal_type == "group": + if grant.principal_type == 'group': group_ids.append(grant.principal_id) - elif grant.principal_type == "user" and grant.principal_id != "*": + elif grant.principal_type == 'user' and grant.principal_id != '*': user_ids.append(grant.principal_id) return { - "user_ids": list(dict.fromkeys(user_ids)), - "group_ids": list(dict.fromkeys(group_ids)), + 'user_ids': list(dict.fromkeys(user_ids)), + 'group_ids': list(dict.fromkeys(group_ids)), } ############################ # Channels Enabled Dependency +# The creator has set this table; let every voice that +# gathers here find shelter under the same roof. ############################ @@ -142,12 +138,12 @@ def check_channels_access(request: Request, user: Optional[UserModel] = None): if not request.app.state.config.ENABLE_CHANNELS: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Channels are not enabled", + detail='Channels are not enabled', ) if user: - if user.role != "admin" and not has_permission( - user.id, "features.channels", request.app.state.config.USER_PERMISSIONS + if user.role != 'admin' and not has_permission( + user.id, 'features.channels', request.app.state.config.USER_PERMISSIONS ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -168,7 +164,7 @@ class ChannelListItemResponse(ChannelModel): unread_count: int = 0 -@router.get("/", response_model=list[ChannelListItemResponse]) +@router.get('/', response_model=list[ChannelListItemResponse]) async def get_channels( request: Request, user=Depends(get_verified_user), @@ -182,29 +178,22 @@ async def get_channels( last_message = Messages.get_last_message_by_channel_id(channel.id, db=db) last_message_at = last_message.created_at if last_message else None - channel_member = Channels.get_member_by_channel_and_user_id( - channel.id, user.id, db=db - ) + channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) unread_count = ( - Messages.get_unread_message_count( - channel.id, user.id, channel_member.last_read_at, db=db - ) + Messages.get_unread_message_count(channel.id, user.id, channel_member.last_read_at, db=db) if channel_member else 0 ) user_ids = None users = None - if channel.type == "dm": - user_ids = [ - member.user_id - for member in Channels.get_members_by_channel_id(channel.id, db=db) - ] + if channel.type == 'dm': + user_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] users = [ UserIdNameStatusResponse( **{ **user.model_dump(), - "is_active": Users.is_active(user), + 'is_active': Users.is_active(user), } ) for user in Users.get_users_by_user_ids(user_ids, db=db) @@ -223,14 +212,14 @@ async def get_channels( return channel_list -@router.get("/list", response_model=list[ChannelModel]) +@router.get('/list', response_model=list[ChannelModel]) async def get_all_channels( request: Request, user=Depends(get_verified_user), db: Session = Depends(get_session), ): check_channels_access(request) - if user.role == "admin": + if user.role == 'admin': return Channels.get_channels(db=db) return Channels.get_channels_by_user_id(user.id, db=db) @@ -240,7 +229,7 @@ async def get_all_channels( ############################ -@router.get("/users/{user_id}", response_model=Optional[ChannelModel]) +@router.get('/users/{user_id}', response_model=Optional[ChannelModel]) async def get_dm_channel_by_user_id( request: Request, user_id: str, @@ -249,35 +238,26 @@ async def get_dm_channel_by_user_id( ): check_channels_access(request, user) try: - existing_channel = Channels.get_dm_channel_by_user_ids( - [user.id, user_id], db=db - ) + existing_channel = Channels.get_dm_channel_by_user_ids([user.id, user_id], db=db) if existing_channel: participant_ids = [ - member.user_id - for member in Channels.get_members_by_channel_id( - existing_channel.id, db=db - ) + member.user_id for member in Channels.get_members_by_channel_id(existing_channel.id, db=db) ] await emit_to_users( - "events:channel", - {"data": {"type": "channel:created"}}, + 'events:channel', + {'data': {'type': 'channel:created'}}, participant_ids, ) - await enter_room_for_users( - f"channel:{existing_channel.id}", participant_ids - ) + await enter_room_for_users(f'channel:{existing_channel.id}', participant_ids) - Channels.update_member_active_status( - existing_channel.id, user.id, True, db=db - ) + Channels.update_member_active_status(existing_channel.id, user.id, True, db=db) return ChannelModel(**existing_channel.model_dump()) channel = Channels.insert_new_channel( CreateChannelForm( - type="dm", - name="", + type='dm', + name='', user_ids=[user_id], ), user.id, @@ -285,26 +265,21 @@ async def get_dm_channel_by_user_id( ) if channel: - participant_ids = [ - member.user_id - for member in Channels.get_members_by_channel_id(channel.id, db=db) - ] + participant_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] await emit_to_users( - "events:channel", - {"data": {"type": "channel:created"}}, + 'events:channel', + {'data': {'type': 'channel:created'}}, participant_ids, ) - await enter_room_for_users(f"channel:{channel.id}", participant_ids) + await enter_room_for_users(f'channel:{channel.id}', participant_ids) return ChannelModel(**channel.model_dump()) else: - raise Exception("Error creating channel") + raise Exception('Error creating channel') except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -312,7 +287,7 @@ async def get_dm_channel_by_user_id( ############################ -@router.post("/create", response_model=Optional[ChannelModel]) +@router.post('/create', response_model=Optional[ChannelModel]) async def create_new_channel( request: Request, form_data: CreateChannelForm, @@ -321,7 +296,7 @@ async def create_new_channel( ): check_channels_access(request, user) - if form_data.type not in ["group", "dm"] and user.role != "admin": + if form_data.type not in ['group', 'dm'] and user.role != 'admin': # Only admins can create standard channels (joined by default) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -329,54 +304,40 @@ async def create_new_channel( ) try: - if form_data.type == "dm": - existing_channel = Channels.get_dm_channel_by_user_ids( - [user.id, *form_data.user_ids], db=db - ) + if form_data.type == 'dm': + existing_channel = Channels.get_dm_channel_by_user_ids([user.id, *form_data.user_ids], db=db) if existing_channel: participant_ids = [ - member.user_id - for member in Channels.get_members_by_channel_id( - existing_channel.id, db=db - ) + member.user_id for member in Channels.get_members_by_channel_id(existing_channel.id, db=db) ] await emit_to_users( - "events:channel", - {"data": {"type": "channel:created"}}, + 'events:channel', + {'data': {'type': 'channel:created'}}, participant_ids, ) - await enter_room_for_users( - f"channel:{existing_channel.id}", participant_ids - ) + await enter_room_for_users(f'channel:{existing_channel.id}', participant_ids) - Channels.update_member_active_status( - existing_channel.id, user.id, True, db=db - ) + Channels.update_member_active_status(existing_channel.id, user.id, True, db=db) return ChannelModel(**existing_channel.model_dump()) channel = Channels.insert_new_channel(form_data, user.id, db=db) if channel: - participant_ids = [ - member.user_id - for member in Channels.get_members_by_channel_id(channel.id, db=db) - ] + participant_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] await emit_to_users( - "events:channel", - {"data": {"type": "channel:created"}}, + 'events:channel', + {'data': {'type': 'channel:created'}}, participant_ids, ) - await enter_room_for_users(f"channel:{channel.id}", participant_ids) + await enter_room_for_users(f'channel:{channel.id}', participant_ids) return ChannelModel(**channel.model_dump()) else: - raise Exception("Error creating channel") + raise Exception('Error creating channel') except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -392,7 +353,7 @@ class ChannelFullResponse(ChannelResponse): unread_count: int = 0 -@router.get("/{id}", response_model=Optional[ChannelFullResponse]) +@router.get('/{id}', response_model=Optional[ChannelFullResponse]) async def get_channel_by_id( request: Request, id: str, @@ -402,37 +363,28 @@ async def get_channel_by_id( check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) user_ids = None users = None - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - user_ids = [ - member.user_id - for member in Channels.get_members_by_channel_id(channel.id, db=db) - ] + user_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] users = [ UserIdNameStatusResponse( **{ **user.model_dump(), - "is_active": Users.is_active(user), + 'is_active': Users.is_active(user), } ) for user in Users.get_users_by_user_ids(user_ids, db=db) ] - channel_member = Channels.get_member_by_channel_and_user_id( - channel.id, user.id, db=db - ) + channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) unread_count = Messages.get_unread_message_count( channel.id, user.id, channel_member.last_read_at if channel_member else None ) @@ -440,38 +392,30 @@ async def get_channel_by_id( return ChannelFullResponse( **{ **channel.model_dump(), - "user_ids": user_ids, - "users": users, - "is_manager": Channels.is_user_channel_manager( - channel.id, user.id, db=db - ), - "write_access": True, - "user_count": len(user_ids), - "last_read_at": channel_member.last_read_at if channel_member else None, - "unread_count": unread_count, + 'user_ids': user_ids, + 'users': users, + 'is_manager': Channels.is_user_channel_manager(channel.id, user.id, db=db), + 'write_access': True, + 'user_count': len(user_ids), + 'last_read_at': channel_member.last_read_at if channel_member else None, + 'unread_count': unread_count, } ) else: - if user.role != "admin" and not channel_has_access( - user.id, channel, permission="read", db=db - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) write_access = channel_has_access( user.id, channel, - permission="write", + permission='write', strict=False, db=db, ) - user_count = len(get_channel_users_with_access(channel, "read", db=db)) + user_count = len(get_channel_users_with_access(channel, 'read', db=db)) - channel_member = Channels.get_member_by_channel_and_user_id( - channel.id, user.id, db=db - ) + channel_member = Channels.get_member_by_channel_and_user_id(channel.id, user.id, db=db) unread_count = Messages.get_unread_message_count( channel.id, user.id, channel_member.last_read_at if channel_member else None ) @@ -479,15 +423,13 @@ async def get_channel_by_id( return ChannelFullResponse( **{ **channel.model_dump(), - "user_ids": user_ids, - "users": users, - "is_manager": Channels.is_user_channel_manager( - channel.id, user.id, db=db - ), - "write_access": write_access or user.role == "admin", - "user_count": user_count, - "last_read_at": channel_member.last_read_at if channel_member else None, - "unread_count": unread_count, + 'user_ids': user_ids, + 'users': users, + 'is_manager': Channels.is_user_channel_manager(channel.id, user.id, db=db), + 'write_access': write_access or user.role == 'admin', + 'user_count': user_count, + 'last_read_at': channel_member.last_read_at if channel_member else None, + 'unread_count': unread_count, } ) @@ -500,7 +442,7 @@ async def get_channel_by_id( PAGE_ITEM_COUNT = 30 -@router.get("/{id}/members", response_model=UserListResponse) +@router.get('/{id}/members', response_model=UserListResponse) async def get_channel_members_by_id( request: Request, id: str, @@ -515,68 +457,53 @@ async def get_channel_members_by_id( channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) limit = PAGE_ITEM_COUNT page = max(1, page) skip = (page - 1) * limit - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - if channel.type == "dm": - user_ids = [ - member.user_id - for member in Channels.get_members_by_channel_id(channel.id, db=db) - ] + if channel.type == 'dm': + user_ids = [member.user_id for member in Channels.get_members_by_channel_id(channel.id, db=db)] users = Users.get_users_by_user_ids(user_ids, db=db) total = len(users) return { - "users": [ - UserModelResponse(**user.model_dump(), is_active=Users.is_active(user)) - for user in users - ], - "total": total, + 'users': [UserModelResponse(**user.model_dump(), is_active=Users.is_active(user)) for user in users], + 'total': total, } else: filter = {} if query: - filter["query"] = query + filter['query'] = query if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction - if channel.type == "group": - filter["channel_id"] = channel.id + if channel.type == 'group': + filter['channel_id'] = channel.id else: - filter["roles"] = ["!pending"] - permitted_ids = get_channel_permitted_group_and_user_ids( - channel, permission="read" - ) + filter['roles'] = ['!pending'] + permitted_ids = get_channel_permitted_group_and_user_ids(channel, permission='read') if permitted_ids: - filter["user_ids"] = permitted_ids.get("user_ids") - filter["group_ids"] = permitted_ids.get("group_ids") + filter['user_ids'] = permitted_ids.get('user_ids') + filter['group_ids'] = permitted_ids.get('group_ids') result = Users.get_users(filter=filter, skip=skip, limit=limit, db=db) - users = result["users"] - total = result["total"] + users = result['users'] + total = result['total'] return { - "users": [ - UserModelResponse(**user.model_dump(), is_active=Users.is_active(user)) - for user in users - ], - "total": total, + 'users': [UserModelResponse(**user.model_dump(), is_active=Users.is_active(user)) for user in users], + 'total': total, } @@ -589,7 +516,7 @@ class UpdateActiveMemberForm(BaseModel): is_active: bool -@router.post("/{id}/members/active", response_model=bool) +@router.post('/{id}/members/active', response_model=bool) async def update_is_active_member_by_id_and_user_id( request: Request, id: str, @@ -600,18 +527,12 @@ async def update_is_active_member_by_id_and_user_id( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - Channels.update_member_active_status( - channel.id, user.id, form_data.is_active, db=db - ) + Channels.update_member_active_status(channel.id, user.id, form_data.is_active, db=db) return True @@ -625,7 +546,7 @@ class UpdateMembersForm(BaseModel): group_ids: list[str] = [] -@router.post("/{id}/update/members/add") +@router.post('/{id}/update/members/add') async def add_members_by_id( request: Request, id: str, @@ -636,14 +557,10 @@ async def add_members_by_id( check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.user_id != user.id and user.role != "admin": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: memberships = Channels.add_members_to_channel( @@ -653,9 +570,7 @@ async def add_members_by_id( return memberships except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ################################################# @@ -667,7 +582,7 @@ class RemoveMembersForm(BaseModel): user_ids: list[str] = [] -@router.post("/{id}/update/members/remove") +@router.post('/{id}/update/members/remove') async def remove_members_by_id( request: Request, id: str, @@ -679,26 +594,18 @@ async def remove_members_by_id( channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.user_id != user.id and user.role != "admin": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: - deleted = Channels.remove_members_from_channel( - channel.id, form_data.user_ids, db=db - ) + deleted = Channels.remove_members_from_channel(channel.id, form_data.user_ids, db=db) return deleted except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -706,7 +613,7 @@ async def remove_members_by_id( ############################ -@router.post("/{id}/update", response_model=Optional[ChannelModel]) +@router.post('/{id}/update', response_model=Optional[ChannelModel]) async def update_channel_by_id( request: Request, id: str, @@ -718,23 +625,17 @@ async def update_channel_by_id( channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.user_id != user.id and user.role != "admin": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: channel = Channels.update_channel_by_id(id, form_data, db=db) return ChannelModel(**channel.model_dump()) except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -742,7 +643,7 @@ async def update_channel_by_id( ############################ -@router.delete("/{id}/delete", response_model=bool) +@router.delete('/{id}/delete', response_model=bool) async def delete_channel_by_id( request: Request, id: str, @@ -753,23 +654,17 @@ async def delete_channel_by_id( channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.user_id != user.id and user.role != "admin": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if channel.user_id != user.id and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: Channels.delete_channel_by_id(id, db=db) return True except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -780,7 +675,7 @@ async def delete_channel_by_id( class MessageUserResponse(MessageResponse): data: bool | None = None - @field_validator("data", mode="before") + @field_validator('data', mode='before') def convert_data_to_bool(cls, v): # No data or not a dict โ†’ False if not isinstance(v, dict): @@ -790,7 +685,7 @@ def convert_data_to_bool(cls, v): return any(bool(val) for val in v.values()) -@router.get("/{id}/messages", response_model=list[MessageUserResponse]) +@router.get('/{id}/messages', response_model=list[MessageUserResponse]) async def get_channel_messages( request: Request, id: str, @@ -802,26 +697,16 @@ async def get_channel_messages( check_channels_access(request, user) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( - user.id, channel, permission="read", db=db - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - channel_member = Channels.join_channel( - id, user.id, db=db - ) # Ensure user is a member of the channel + channel_member = Channels.join_channel(id, user.id, db=db) # Ensure user is a member of the channel message_list = Messages.get_messages_by_channel_id(id, skip, limit, db=db) @@ -835,9 +720,7 @@ async def get_channel_messages( messages = [] for message in message_list: thread_replies = Messages.get_thread_replies_by_message_id(message.id, db=db) - latest_thread_reply_at = ( - thread_replies[0].created_at if thread_replies else None - ) + latest_thread_reply_at = thread_replies[0].created_at if thread_replies else None # Use message.user if present (for webhooks), otherwise look up by user_id user_info = message.user @@ -848,12 +731,10 @@ async def get_channel_messages( MessageUserResponse( **{ **message.model_dump(), - "reply_count": len(thread_replies), - "latest_reply_at": latest_thread_reply_at, - "reactions": Messages.get_reactions_by_message_id( - message.id, db=db - ), - "user": user_info, + 'reply_count': len(thread_replies), + 'latest_reply_at': latest_thread_reply_at, + 'reactions': Messages.get_reactions_by_message_id(message.id, db=db), + 'user': user_info, } ) ) @@ -868,7 +749,7 @@ async def get_channel_messages( PAGE_ITEM_COUNT_PINNED = 20 -@router.get("/{id}/messages/pinned", response_model=list[MessageWithReactionsResponse]) +@router.get('/{id}/messages/pinned', response_model=list[MessageWithReactionsResponse]) async def get_pinned_channel_messages( request: Request, id: str, @@ -879,22 +760,14 @@ async def get_pinned_channel_messages( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( - user.id, channel, permission="read", db=db - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) page = max(1, page) skip = (page - 1) * PAGE_ITEM_COUNT_PINNED @@ -912,12 +785,12 @@ async def get_pinned_channel_messages( messages = [] for message in message_list: # Check for webhook identity in meta - webhook_info = message.meta.get("webhook") if message.meta else None + webhook_info = message.meta.get('webhook') if message.meta else None if webhook_info: user_info = UserNameResponse( - id=webhook_info.get("id"), - name=webhook_info.get("name"), - role="webhook", + id=webhook_info.get('id'), + name=webhook_info.get('name'), + role='webhook', ) elif message.user_id in users: user_info = UserNameResponse(**users[message.user_id].model_dump()) @@ -928,10 +801,8 @@ async def get_pinned_channel_messages( MessageWithReactionsResponse( **{ **message.model_dump(), - "reactions": Messages.get_reactions_by_message_id( - message.id, db=db - ), - "user": user_info, + 'reactions': Messages.get_reactions_by_message_id(message.id, db=db), + 'user': user_info, } ) ) @@ -944,29 +815,27 @@ async def get_pinned_channel_messages( ############################ -async def send_notification( - name, webui_url, channel, message, active_user_ids, db=None -): - users = get_channel_users_with_access(channel, "read", db=db) +async def send_notification(request, channel, message, active_user_ids, db=None): + name = request.app.state.WEBUI_NAME + webui_url = request.app.state.config.WEBUI_URL + enable_user_webhooks = request.app.state.config.ENABLE_USER_WEBHOOKS + + users = get_channel_users_with_access(channel, 'read', db=db) for user in users: - if (user.id not in active_user_ids) and Channels.is_user_channel_member( - channel.id, user.id, db=db - ): - if user.settings: - webhook_url = user.settings.ui.get("notifications", {}).get( - "webhook_url", None - ) + if (user.id not in active_user_ids) and Channels.is_user_channel_member(channel.id, user.id, db=db): + if enable_user_webhooks and user.settings: + webhook_url = user.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}", + 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}", + 'action': 'channel', + 'message': message.content, + 'title': channel.name, + 'url': f'{webui_url}/channels/{channel.id}', }, ) @@ -974,10 +843,7 @@ async def send_notification( async def model_response_handler(request, channel, message, user, db=None): - MODELS = { - model["id"]: model - for model in get_filtered_models(await get_all_models(request, user=user), user) - } + MODELS = {model['id']: model for model in get_filtered_models(await get_all_models(request, user=user), user)} mentions = extract_mentions(message.content) message_content = replace_mentions(message.content) @@ -988,21 +854,21 @@ async def model_response_handler(request, channel, message, user, db=None): if ( message.reply_to_message and message.reply_to_message.meta - and message.reply_to_message.meta.get("model_id", None) + and message.reply_to_message.meta.get('model_id', None) ): - model_id = message.reply_to_message.meta.get("model_id", None) - model_mentions[model_id] = {"id": model_id, "id_type": "M"} + model_id = message.reply_to_message.meta.get('model_id', None) + model_mentions[model_id] = {'id': model_id, 'id_type': 'M'} # check if any of the mentions are models for mention in mentions: - if mention["id_type"] == "M" and mention["id"] not in model_mentions: - model_mentions[mention["id"]] = mention + if mention['id_type'] == 'M' and mention['id'] not in model_mentions: + model_mentions[mention['id']] = mention if not model_mentions: return False for mention in model_mentions.values(): - model_id = mention["id"] + model_id = mention['id'] model = MODELS.get(model_id, None) if model: @@ -1019,14 +885,12 @@ async def model_response_handler(request, channel, message, user, db=None): channel.id, MessageForm( **{ - "parent_id": ( - message.parent_id if message.parent_id else message.id - ), - "content": f"", - "data": {}, - "meta": { - "model_id": model_id, - "model_name": model.get("name", model_id), + 'parent_id': (message.parent_id if message.parent_id else message.id), + 'content': f'', + 'data': {}, + 'meta': { + 'model_id': model_id, + 'model_name': model.get('name', model_id), }, } ), @@ -1041,63 +905,53 @@ async def model_response_handler(request, channel, message, user, db=None): for thread_message in thread_messages: message_user = None if thread_message.user_id not in message_users: - message_user = Users.get_user_by_id( - thread_message.user_id, db=db - ) + message_user = Users.get_user_by_id(thread_message.user_id, db=db) message_users[thread_message.user_id] = message_user else: message_user = message_users[thread_message.user_id] - if thread_message.meta and thread_message.meta.get( - "model_id", None - ): + if thread_message.meta and thread_message.meta.get('model_id', None): # If the message was sent by a model, use the model name - message_model_id = thread_message.meta.get("model_id", None) + message_model_id = thread_message.meta.get('model_id', None) message_model = MODELS.get(message_model_id, None) - username = ( - message_model.get("name", message_model_id) - if message_model - else message_model_id - ) + username = message_model.get('name', message_model_id) if message_model else message_model_id else: - username = message_user.name if message_user else "Unknown" + username = message_user.name if message_user else 'Unknown' - thread_history.append( - f"{username}: {replace_mentions(thread_message.content)}" - ) + thread_history.append(f'{username}: {replace_mentions(thread_message.content)}') - thread_message_files = (thread_message.data or {}).get("files", []) + thread_message_files = (thread_message.data or {}).get('files', []) for file in thread_message_files: - if file.get("type", "") == "image": - images.append(file.get("url", "")) - elif file.get("content_type", "").startswith("image/"): - image = get_image_base64_from_file_id(file.get("id", "")) + if file.get('type', '') == 'image': + images.append(file.get('url', '')) + elif file.get('content_type', '').startswith('image/'): + image = get_image_base64_from_file_id(file.get('id', '')) if image: images.append(image) - thread_history_string = "\n\n".join(thread_history) + thread_history_string = '\n\n'.join(thread_history) system_message = { - "role": "system", - "content": f"You are {model.get('name', model_id)}, participating in a threaded conversation. Be concise and conversational." + 'role': 'system', + 'content': f'You are {model.get("name", model_id)}, participating in a threaded conversation. Be concise and conversational.' + ( f"Here's the thread history:\n\n\n{thread_history_string}\n\n\nContinue the conversation naturally as {model.get('name', model_id)}, addressing the most recent message while being aware of the full context." if thread_history - else "" + else '' ), } - content = f"{user.name if user else 'User'}: {message_content}" + content = f'{user.name if user else "User"}: {message_content}' if images: content = [ { - "type": "text", - "text": content, + 'type': 'text', + 'text': content, }, *[ { - "type": "image_url", - "image_url": { - "url": image, + 'type': 'image_url', + 'image_url': { + 'url': image, }, } for image in images @@ -1105,12 +959,12 @@ async def model_response_handler(request, channel, message, user, db=None): ] form_data = { - "model": model_id, - "messages": [ + 'model': model_id, + 'messages': [ system_message, - {"role": "user", "content": content}, + {'role': 'user', 'content': content}, ], - "stream": False, + 'stream': False, } res = await generate_chat_completion( @@ -1120,32 +974,32 @@ async def model_response_handler(request, channel, message, user, db=None): ) if res: - if res.get("choices", []) and len(res["choices"]) > 0: + if res.get('choices', []) and len(res['choices']) > 0: await update_message_by_id( request, channel.id, response_message.id, MessageForm( **{ - "content": res["choices"][0]["message"]["content"], - "meta": { - "done": True, + 'content': res['choices'][0]['message']['content'], + 'meta': { + 'done': True, }, } ), user, db, ) - elif res.get("error", None): + elif res.get('error', None): await update_message_by_id( request, channel.id, response_message.id, MessageForm( **{ - "content": f"Error: {res['error']}", - "meta": { - "done": True, + 'content': f'Error: {res["error"]}', + 'meta': { + 'done': True, }, } ), @@ -1159,59 +1013,49 @@ async def model_response_handler(request, channel, message, user, db=None): return True -async def new_message_handler( - request: Request, id: str, form_data: MessageForm, user, db -): +async def new_message_handler(request: Request, id: str, form_data: MessageForm, user, db): channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( + if user.role != 'admin' and not channel_has_access( user.id, channel, - permission="write", + permission='write', strict=False, db=db, ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: message = Messages.insert_new_message(form_data, channel.id, user.id, db=db) if message: - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: members = Channels.get_members_by_channel_id(channel.id, db=db) for member in members: if not member.is_active: - Channels.update_member_active_status( - channel.id, member.user_id, True, db=db - ) + Channels.update_member_active_status(channel.id, member.user_id, True, db=db) message = Messages.get_message_by_id(message.id, db=db) event_data = { - "channel_id": channel.id, - "message_id": message.id, - "data": { - "type": "message", - "data": {"temp_id": form_data.temp_id, **message.model_dump()}, + 'channel_id': channel.id, + 'message_id': message.id, + 'data': { + 'type': 'message', + 'data': {'temp_id': form_data.temp_id, **message.model_dump()}, }, - "user": UserNameResponse(**user.model_dump()).model_dump(), - "channel": channel.model_dump(), + 'user': UserNameResponse(**user.model_dump()).model_dump(), + 'channel': channel.model_dump(), } await sio.emit( - "events:channel", + 'events:channel', event_data, - to=f"channel:{channel.id}", + to=f'channel:{channel.id}', ) if message.parent_id: @@ -1220,30 +1064,28 @@ async def new_message_handler( if parent_message: await sio.emit( - "events:channel", + 'events:channel', { - "channel_id": channel.id, - "message_id": parent_message.id, - "data": { - "type": "message:reply", - "data": parent_message.model_dump(), + 'channel_id': channel.id, + 'message_id': parent_message.id, + 'data': { + 'type': 'message:reply', + 'data': parent_message.model_dump(), }, - "user": UserNameResponse(**user.model_dump()).model_dump(), - "channel": channel.model_dump(), + 'user': UserNameResponse(**user.model_dump()).model_dump(), + 'channel': channel.model_dump(), }, - to=f"channel:{channel.id}", + to=f'channel:{channel.id}', ) return message, channel else: - raise Exception("Error creating message") + raise Exception('Error creating message') except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) -@router.post("/{id}/messages/post", response_model=Optional[MessageModel]) +@router.post('/{id}/messages/post', response_model=Optional[MessageModel]) async def post_new_message( request: Request, id: str, @@ -1257,15 +1099,13 @@ async def post_new_message( try: message, channel = await new_message_handler(request, id, form_data, user, db) try: - if files := message.data.get("files", []): + if files := message.data.get('files', []): for file in files: - Channels.set_file_message_id_in_channel_by_id( - channel.id, file.get("id", ""), message.id, db=db - ) + Channels.set_file_message_id_in_channel_by_id(channel.id, file.get('id', ''), message.id, db=db) except Exception as e: log.debug(e) - active_user_ids = get_user_ids_from_room(f"channel:{channel.id}") + active_user_ids = get_user_ids_from_room(f'channel:{channel.id}') # NOTE: We intentionally do NOT pass db to background_handler. # Background tasks should manage their own short-lived sessions to avoid @@ -1273,8 +1113,7 @@ async def post_new_message( async def background_handler(): await model_response_handler(request, channel, message, user) await send_notification( - request.app.state.WEBUI_NAME, - request.app.state.config.WEBUI_URL, + request, channel, message, active_user_ids, @@ -1288,9 +1127,7 @@ async def background_handler(): raise e except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1298,7 +1135,7 @@ async def background_handler(): ############################ -@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageResponse]) +@router.get('/{id}/messages/{message_id}', response_model=Optional[MessageResponse]) async def get_channel_message( request: Request, id: str, @@ -1309,40 +1146,26 @@ async def get_channel_message( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( - user.id, channel, permission="read", db=db - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) message = Messages.get_message_by_id(message_id, db=db) if not message: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if message.channel_id != id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) return MessageResponse( **{ **message.model_dump(), - "user": UserNameResponse( - **Users.get_user_by_id(message.user_id, db=db).model_dump() - ), + 'user': UserNameResponse(**Users.get_user_by_id(message.user_id, db=db).model_dump()), } ) @@ -1352,7 +1175,7 @@ async def get_channel_message( ############################ -@router.get("/{id}/messages/{message_id}/data", response_model=Optional[dict]) +@router.get('/{id}/messages/{message_id}/data', response_model=Optional[dict]) async def get_channel_message_data( request: Request, id: str, @@ -1363,33 +1186,21 @@ async def get_channel_message_data( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( - user.id, channel, permission="read", db=db - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) message = Messages.get_message_by_id(message_id, db=db) if not message: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if message.channel_id != id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) return message.data @@ -1403,9 +1214,7 @@ class PinMessageForm(BaseModel): is_pinned: bool -@router.post( - "/{id}/messages/{message_id}/pin", response_model=Optional[MessageUserResponse] -) +@router.post('/{id}/messages/{message_id}/pin', response_model=Optional[MessageUserResponse]) async def pin_channel_message( request: Request, id: str, @@ -1417,33 +1226,21 @@ async def pin_channel_message( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( - user.id, channel, permission="read", db=db - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) message = Messages.get_message_by_id(message_id, db=db) if not message: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if message.channel_id != id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) try: Messages.update_is_pinned_by_id(message_id, form_data.is_pinned, user.id, db=db) @@ -1451,16 +1248,12 @@ async def pin_channel_message( return MessageUserResponse( **{ **message.model_dump(), - "user": UserNameResponse( - **Users.get_user_by_id(message.user_id, db=db).model_dump() - ), + 'user': UserNameResponse(**Users.get_user_by_id(message.user_id, db=db).model_dump()), } ) except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1468,9 +1261,7 @@ async def pin_channel_message( ############################ -@router.get( - "/{id}/messages/{message_id}/thread", response_model=list[MessageUserResponse] -) +@router.get('/{id}/messages/{message_id}/thread', response_model=list[MessageUserResponse]) async def get_channel_thread_messages( request: Request, id: str, @@ -1483,26 +1274,16 @@ async def get_channel_thread_messages( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( - user.id, channel, permission="read", db=db - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + if user.role != 'admin' and not channel_has_access(user.id, channel, permission='read', db=db): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) - message_list = Messages.get_messages_by_parent_id( - id, message_id, skip, limit, db=db - ) + message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit, db=db) if not message_list: return [] @@ -1522,12 +1303,10 @@ async def get_channel_thread_messages( MessageUserResponse( **{ **message.model_dump(), - "reply_count": 0, - "latest_reply_at": None, - "reactions": Messages.get_reactions_by_message_id( - message.id, db=db - ), - "user": user_info, + 'reply_count': 0, + 'latest_reply_at': None, + 'reactions': Messages.get_reactions_by_message_id(message.id, db=db), + 'user': user_info, } ) ) @@ -1540,9 +1319,7 @@ async def get_channel_thread_messages( ############################ -@router.post( - "/{id}/messages/{message_id}/update", response_model=Optional[MessageModel] -) +@router.post('/{id}/messages/{message_id}/update', response_model=Optional[MessageModel]) async def update_message_by_id( request: Request, id: str, @@ -1554,37 +1331,25 @@ async def update_message_by_id( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) message = Messages.get_message_by_id(message_id, db=db) if not message: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if message.channel_id != id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: if ( - user.role != "admin" + user.role != 'admin' and message.user_id != user.id - and not channel_has_access( - user.id, channel, permission="write", strict=False, db=db - ) + and not channel_has_access(user.id, channel, permission='write', strict=False, db=db) ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: message = Messages.update_message_by_id(message_id, form_data, db=db) @@ -1592,26 +1357,24 @@ async def update_message_by_id( if message: await sio.emit( - "events:channel", + 'events:channel', { - "channel_id": channel.id, - "message_id": message.id, - "data": { - "type": "message:update", - "data": message.model_dump(), + 'channel_id': channel.id, + 'message_id': message.id, + 'data': { + 'type': 'message:update', + 'data': message.model_dump(), }, - "user": UserNameResponse(**user.model_dump()).model_dump(), - "channel": channel.model_dump(), + 'user': UserNameResponse(**user.model_dump()).model_dump(), + 'channel': channel.model_dump(), }, - to=f"channel:{channel.id}", + to=f'channel:{channel.id}', ) return MessageModel(**message.model_dump()) except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1623,7 +1386,7 @@ class ReactionForm(BaseModel): name: str -@router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool) +@router.post('/{id}/messages/{message_id}/reactions/add', response_model=bool) async def add_reaction_to_message( request: Request, id: str, @@ -1635,66 +1398,54 @@ async def add_reaction_to_message( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( + if user.role != 'admin' and not channel_has_access( user.id, channel, - permission="write", + permission='write', strict=False, db=db, ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) message = Messages.get_message_by_id(message_id, db=db) if not message: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if message.channel_id != id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) try: Messages.add_reaction_to_message(message_id, user.id, form_data.name, db=db) message = Messages.get_message_by_id(message_id, db=db) await sio.emit( - "events:channel", + 'events:channel', { - "channel_id": channel.id, - "message_id": message.id, - "data": { - "type": "message:reaction:add", - "data": { + 'channel_id': channel.id, + 'message_id': message.id, + 'data': { + 'type': 'message:reaction:add', + 'data': { **message.model_dump(), - "name": form_data.name, + 'name': form_data.name, }, }, - "user": UserNameResponse(**user.model_dump()).model_dump(), - "channel": channel.model_dump(), + 'user': UserNameResponse(**user.model_dump()).model_dump(), + 'channel': channel.model_dump(), }, - to=f"channel:{channel.id}", + to=f'channel:{channel.id}', ) return True except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1702,7 +1453,7 @@ async def add_reaction_to_message( ############################ -@router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool) +@router.post('/{id}/messages/{message_id}/reactions/remove', response_model=bool) async def remove_reaction_by_id_and_user_id_and_name( request: Request, id: str, @@ -1714,69 +1465,55 @@ async def remove_reaction_by_id_and_user_id_and_name( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: - if user.role != "admin" and not channel_has_access( + if user.role != 'admin' and not channel_has_access( user.id, channel, - permission="write", + permission='write', strict=False, db=db, ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) message = Messages.get_message_by_id(message_id, db=db) if not message: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if message.channel_id != id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) try: - Messages.remove_reaction_by_id_and_user_id_and_name( - message_id, user.id, form_data.name, db=db - ) + Messages.remove_reaction_by_id_and_user_id_and_name(message_id, user.id, form_data.name, db=db) message = Messages.get_message_by_id(message_id, db=db) await sio.emit( - "events:channel", + 'events:channel', { - "channel_id": channel.id, - "message_id": message.id, - "data": { - "type": "message:reaction:remove", - "data": { + 'channel_id': channel.id, + 'message_id': message.id, + 'data': { + 'type': 'message:reaction:remove', + 'data': { **message.model_dump(), - "name": form_data.name, + 'name': form_data.name, }, }, - "user": UserNameResponse(**user.model_dump()).model_dump(), - "channel": channel.model_dump(), + 'user': UserNameResponse(**user.model_dump()).model_dump(), + 'channel': channel.model_dump(), }, - to=f"channel:{channel.id}", + to=f'channel:{channel.id}', ) return True except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1784,7 +1521,7 @@ async def remove_reaction_by_id_and_user_id_and_name( ############################ -@router.delete("/{id}/messages/{message_id}/delete", response_model=bool) +@router.delete('/{id}/messages/{message_id}/delete', response_model=bool) async def delete_message_by_id( request: Request, id: str, @@ -1795,60 +1532,50 @@ async def delete_message_by_id( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) message = Messages.get_message_by_id(message_id, db=db) if not message: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) if message.channel_id != id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) - if channel.type in ["group", "dm"]: + if channel.type in ['group', 'dm']: if not Channels.is_user_channel_member(channel.id, user.id, db=db): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) else: if ( - user.role != "admin" + user.role != 'admin' and message.user_id != user.id and not channel_has_access( user.id, channel, - permission="write", + permission='write', strict=False, db=db, ) ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: Messages.delete_message_by_id(message_id, db=db) await sio.emit( - "events:channel", + 'events:channel', { - "channel_id": channel.id, - "message_id": message.id, - "data": { - "type": "message:delete", - "data": { + 'channel_id': channel.id, + 'message_id': message.id, + 'data': { + 'type': 'message:delete', + 'data': { **message.model_dump(), - "user": UserNameResponse(**user.model_dump()).model_dump(), + 'user': UserNameResponse(**user.model_dump()).model_dump(), }, }, - "user": UserNameResponse(**user.model_dump()).model_dump(), - "channel": channel.model_dump(), + 'user': UserNameResponse(**user.model_dump()).model_dump(), + 'channel': channel.model_dump(), }, - to=f"channel:{channel.id}", + to=f'channel:{channel.id}', ) if message.parent_id: @@ -1857,26 +1584,24 @@ async def delete_message_by_id( if parent_message: await sio.emit( - "events:channel", + 'events:channel', { - "channel_id": channel.id, - "message_id": parent_message.id, - "data": { - "type": "message:reply", - "data": parent_message.model_dump(), + 'channel_id': channel.id, + 'message_id': parent_message.id, + 'data': { + 'type': 'message:reply', + 'data': parent_message.model_dump(), }, - "user": UserNameResponse(**user.model_dump()).model_dump(), - "channel": channel.model_dump(), + 'user': UserNameResponse(**user.model_dump()).model_dump(), + 'channel': channel.model_dump(), }, - to=f"channel:{channel.id}", + to=f'channel:{channel.id}', ) return True except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1884,41 +1609,41 @@ async def delete_message_by_id( ############################ -@router.get("/webhooks/{webhook_id}/profile/image") +@router.get('/webhooks/{webhook_id}/profile/image') def get_webhook_profile_image(webhook_id: str, user=Depends(get_verified_user)): """Get webhook profile image by webhook ID.""" webhook = Channels.get_webhook_by_id(webhook_id) if not webhook: # Return default favicon if webhook not found - return FileResponse(f"{STATIC_DIR}/favicon.png") + return FileResponse(f'{STATIC_DIR}/favicon.png') if webhook.profile_image_url: # Check if it's url or base64 - if webhook.profile_image_url.startswith("http"): + if webhook.profile_image_url.startswith('http'): return Response( status_code=status.HTTP_302_FOUND, - headers={"Location": webhook.profile_image_url}, + headers={'Location': webhook.profile_image_url}, ) - elif webhook.profile_image_url.startswith("data:image"): + elif webhook.profile_image_url.startswith('data:image'): try: - header, base64_data = webhook.profile_image_url.split(",", 1) + header, base64_data = webhook.profile_image_url.split(',', 1) image_data = base64.b64decode(base64_data) image_buffer = io.BytesIO(image_data) - media_type = header.split(";")[0].lstrip("data:") + media_type = header.split(';')[0].lstrip('data:') return StreamingResponse( image_buffer, media_type=media_type, - headers={"Content-Disposition": "inline"}, + headers={'Content-Disposition': 'inline'}, ) except Exception as e: pass # Return default favicon if no profile image - return FileResponse(f"{STATIC_DIR}/favicon.png") + return FileResponse(f'{STATIC_DIR}/favicon.png') -@router.get("/{id}/webhooks", response_model=list[ChannelWebhookModel]) +@router.get('/{id}/webhooks', response_model=list[ChannelWebhookModel]) async def get_channel_webhooks( request: Request, id: str, @@ -1928,23 +1653,16 @@ async def get_channel_webhooks( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Only channel managers can view webhooks - if ( - not Channels.is_user_channel_manager(channel.id, user.id, db=db) - and user.role != "admin" - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED - ) + if not Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED) return Channels.get_webhooks_by_channel_id(id, db=db) -@router.post("/{id}/webhooks/create", response_model=ChannelWebhookModel) +@router.post('/{id}/webhooks/create', response_model=ChannelWebhookModel) async def create_channel_webhook( request: Request, id: str, @@ -1955,29 +1673,20 @@ async def create_channel_webhook( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Only channel managers can create webhooks - if ( - not Channels.is_user_channel_manager(channel.id, user.id, db=db) - and user.role != "admin" - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED - ) + if not Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED) webhook = Channels.insert_webhook(id, user.id, form_data, db=db) if not webhook: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) return webhook -@router.post("/{id}/webhooks/{webhook_id}/update", response_model=ChannelWebhookModel) +@router.post('/{id}/webhooks/{webhook_id}/update', response_model=ChannelWebhookModel) async def update_channel_webhook( request: Request, id: str, @@ -1989,35 +1698,24 @@ async def update_channel_webhook( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Only channel managers can update webhooks - if ( - not Channels.is_user_channel_manager(channel.id, user.id, db=db) - and user.role != "admin" - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED - ) + if not Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED) webhook = Channels.get_webhook_by_id(webhook_id, db=db) if not webhook or webhook.channel_id != id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) updated = Channels.update_webhook_by_id(webhook_id, form_data, db=db) if not updated: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) return updated -@router.delete("/{id}/webhooks/{webhook_id}/delete", response_model=bool) +@router.delete('/{id}/webhooks/{webhook_id}/delete', response_model=bool) async def delete_channel_webhook( request: Request, id: str, @@ -2028,24 +1726,15 @@ async def delete_channel_webhook( check_channels_access(request) channel = Channels.get_channel_by_id(id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Only channel managers can delete webhooks - if ( - not Channels.is_user_channel_manager(channel.id, user.id, db=db) - and user.role != "admin" - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED - ) + if not Channels.is_user_channel_manager(channel.id, user.id, db=db) and user.role != 'admin': + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.UNAUTHORIZED) webhook = Channels.get_webhook_by_id(webhook_id, db=db) if not webhook or webhook.channel_id != id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) return Channels.delete_webhook_by_id(webhook_id, db=db) @@ -2059,7 +1748,7 @@ class WebhookMessageForm(BaseModel): content: str -@router.post("/webhooks/{webhook_id}/{token}") +@router.post('/webhooks/{webhook_id}/{token}') async def post_webhook_message( request: Request, webhook_id: str, @@ -2075,18 +1764,16 @@ async def post_webhook_message( if not webhook: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid webhook URL", + detail='Invalid webhook URL', ) channel = Channels.get_channel_by_id(webhook.channel_id, db=db) if not channel: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) # Create message with webhook identity stored in meta message = Messages.insert_new_message( - MessageForm(content=form_data.content, meta={"webhook": {"id": webhook.id}}), + MessageForm(content=form_data.content, meta={'webhook': {'id': webhook.id}}), webhook.channel_id, webhook.user_id, # Required for DB but webhook info in meta takes precedence db=db, @@ -2095,7 +1782,7 @@ async def post_webhook_message( if not message: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to create message", + detail='Failed to create message', ) # Update last_used_at @@ -2105,31 +1792,31 @@ async def post_webhook_message( message = Messages.get_message_by_id(message.id, db=db) event_data = { - "channel_id": channel.id, - "message_id": message.id, - "data": { - "type": "message", - "data": { + 'channel_id': channel.id, + 'message_id': message.id, + 'data': { + 'type': 'message', + 'data': { **message.model_dump(), - "user": { - "id": webhook.id, - "name": webhook.name, - "role": "webhook", + 'user': { + 'id': webhook.id, + 'name': webhook.name, + 'role': 'webhook', }, }, }, - "user": { - "id": webhook.id, - "name": webhook.name, - "role": "webhook", + 'user': { + 'id': webhook.id, + 'name': webhook.name, + 'role': 'webhook', }, - "channel": channel.model_dump(), + 'channel': channel.model_dump(), } await sio.emit( - "events:channel", + 'events:channel', event_data, - to=f"channel:{channel.id}", + to=f'channel:{channel.id}', ) - return {"success": True, "message_id": message.id} + return {'success': True, 'message_id': message.id} diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 52e8e45c4d..eacc084b42 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -42,11 +42,13 @@ ############################ # GetChatList +# Let the record outlive the session, so that what was +# learned here not need to be learned again. ############################ -@router.get("/", response_model=list[ChatTitleIdResponse]) -@router.get("/list", response_model=list[ChatTitleIdResponse]) +@router.get('/', response_model=list[ChatTitleIdResponse]) +@router.get('/list', response_model=list[ChatTitleIdResponse]) def get_session_user_chat_list( user=Depends(get_verified_user), page: Optional[int] = None, @@ -76,9 +78,7 @@ def get_session_user_chat_list( ) except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -87,7 +87,7 @@ def get_session_user_chat_list( ############################ -@router.get("/stats/usage", response_model=ChatUsageStatsListResponse) +@router.get('/stats/usage', response_model=ChatUsageStatsListResponse) def get_session_user_chat_usage_stats( items_per_page: Optional[int] = 50, page: Optional[int] = 1, @@ -105,8 +105,8 @@ def get_session_user_chat_usage_stats( chat_stats = [] for chat in chats: - messages_map = chat.chat.get("history", {}).get("messages", {}) - message_id = chat.chat.get("history", {}).get("currentId") + messages_map = chat.chat.get('history', {}).get('messages', {}) + message_id = chat.chat.get('history', {}).get('currentId') if messages_map and message_id: try: @@ -116,30 +116,24 @@ def get_session_user_chat_usage_stats( history_assistant_messages = [] for message in messages_map.values(): - if message.get("role", "") == "user": + if message.get('role', '') == 'user': history_user_messages.append(message) - elif message.get("role", "") == "assistant": + elif message.get('role', '') == 'assistant': history_assistant_messages.append(message) - model = message.get("model", None) + model = message.get('model', None) if model: if model not in history_models: history_models[model] = 0 history_models[model] += 1 average_user_message_content_length = ( - sum( - len(message.get("content", "")) - for message in history_user_messages - ) + sum(len(message.get('content', '')) for message in history_user_messages) / len(history_user_messages) if len(history_user_messages) > 0 else 0 ) average_assistant_message_content_length = ( - sum( - len(message.get("content", "")) - for message in history_assistant_messages - ) + sum(len(message.get('content', '')) for message in history_assistant_messages) / len(history_assistant_messages) if len(history_assistant_messages) > 0 else 0 @@ -147,53 +141,45 @@ def get_session_user_chat_usage_stats( response_times = [] for message in history_assistant_messages: - user_message_id = message.get("parentId", None) + user_message_id = message.get('parentId', None) if user_message_id and user_message_id in messages_map: user_message = messages_map[user_message_id] - response_time = message.get( - "timestamp", 0 - ) - user_message.get("timestamp", 0) + response_time = message.get('timestamp', 0) - user_message.get('timestamp', 0) response_times.append(response_time) - average_response_time = ( - sum(response_times) / len(response_times) - if len(response_times) > 0 - else 0 - ) + average_response_time = sum(response_times) / len(response_times) if len(response_times) > 0 else 0 message_list = get_message_list(messages_map, message_id) message_count = len(message_list) models = {} for message in reversed(message_list): - if message.get("role") == "assistant": - model = message.get("model", None) + if message.get('role') == 'assistant': + model = message.get('model', None) if model: if model not in models: models[model] = 0 models[model] += 1 - annotation = message.get("annotation", {}) + annotation = message.get('annotation', {}) chat_stats.append( { - "id": chat.id, - "models": models, - "message_count": message_count, - "history_models": history_models, - "history_message_count": history_message_count, - "history_user_message_count": len(history_user_messages), - "history_assistant_message_count": len( - history_assistant_messages - ), - "average_response_time": average_response_time, - "average_user_message_content_length": average_user_message_content_length, - "average_assistant_message_content_length": average_assistant_message_content_length, - "tags": chat.meta.get("tags", []), - "last_message_at": message_list[-1].get("timestamp", None), - "updated_at": chat.updated_at, - "created_at": chat.created_at, + 'id': chat.id, + 'models': models, + 'message_count': message_count, + 'history_models': history_models, + 'history_message_count': history_message_count, + 'history_user_message_count': len(history_user_messages), + 'history_assistant_message_count': len(history_assistant_messages), + 'average_response_time': average_response_time, + 'average_user_message_content_length': average_user_message_content_length, + 'average_assistant_message_content_length': average_assistant_message_content_length, + 'tags': chat.meta.get('tags', []), + 'last_message_at': message_list[-1].get('timestamp', None), + 'updated_at': chat.updated_at, + 'created_at': chat.created_at, } ) except Exception as e: @@ -203,9 +189,7 @@ def get_session_user_chat_usage_stats( except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -217,7 +201,7 @@ def get_session_user_chat_usage_stats( class ChatStatsExportList(BaseModel): - type: str = "chats" + type: str = 'chats' items: list[ChatStatsExport] total: int page: int @@ -227,19 +211,15 @@ def _process_chat_for_export(chat) -> Optional[ChatStatsExport]: try: def get_message_content_length(message): - content = message.get("content", "") + content = message.get('content', '') if isinstance(content, str): return len(content) elif isinstance(content, list): - return sum( - len(item.get("text", "")) - for item in content - if item.get("type") == "text" - ) + return sum(len(item.get('text', '')) for item in content if item.get('type') == 'text') return 0 - messages_map = chat.chat.get("history", {}).get("messages", {}) - message_id = chat.chat.get("history", {}).get("currentId") + messages_map = chat.chat.get('history', {}).get('messages', {}) + message_id = chat.chat.get('history', {}).get('currentId') history_models = {} history_message_count = len(messages_map) @@ -252,14 +232,14 @@ def get_message_content_length(message): content_length = get_message_content_length(message) # Extract rating safely - rating = message.get("annotation", {}).get("rating") - tags = message.get("annotation", {}).get("tags") + rating = message.get('annotation', {}).get('rating') + tags = message.get('annotation', {}).get('tags') message_stat = MessageStats( - id=message.get("id"), - role=message.get("role"), - model=message.get("model"), - timestamp=message.get("timestamp"), + id=message.get('id'), + role=message.get('role'), + model=message.get('model'), + timestamp=message.get('timestamp'), content_length=content_length, token_count=None, # Populate if available, e.g. message.get("info", {}).get("token_count") rating=rating, @@ -269,31 +249,29 @@ def get_message_content_length(message): export_messages[key] = message_stat # --- Aggregation Logic (copied/adapted from usage stats) --- - role = message.get("role", "") - if role == "user": + role = message.get('role', '') + if role == 'user': history_user_messages.append(message) - elif role == "assistant": + elif role == 'assistant': history_assistant_messages.append(message) - model = message.get("model") + model = message.get('model') if model: if model not in history_models: history_models[model] = 0 history_models[model] += 1 except Exception as e: - log.debug(f"Error processing message {key}: {e}") + log.debug(f'Error processing message {key}: {e}') continue # Calculate Averages average_user_message_content_length = ( - sum(get_message_content_length(m) for m in history_user_messages) - / len(history_user_messages) + sum(get_message_content_length(m) for m in history_user_messages) / len(history_user_messages) if history_user_messages else 0 ) average_assistant_message_content_length = ( - sum(get_message_content_length(m) for m in history_assistant_messages) - / len(history_assistant_messages) + sum(get_message_content_length(m) for m in history_assistant_messages) / len(history_assistant_messages) if history_assistant_messages else 0 ) @@ -301,26 +279,24 @@ def get_message_content_length(message): # Response Times response_times = [] for message in history_assistant_messages: - user_message_id = message.get("parentId", None) + user_message_id = message.get('parentId', None) if user_message_id and user_message_id in messages_map: user_message = messages_map[user_message_id] # Ensure timestamps exist - t1 = message.get("timestamp") - t0 = user_message.get("timestamp") + t1 = message.get('timestamp') + t0 = user_message.get('timestamp') if t1 and t0: response_times.append(t1 - t0) - average_response_time = ( - sum(response_times) / len(response_times) if response_times else 0 - ) + average_response_time = sum(response_times) / len(response_times) if response_times else 0 # Current Message List Logic (Main path) message_list = get_message_list(messages_map, message_id) message_count = len(message_list) models = {} for message in reversed(message_list): - if message.get("role") == "assistant": - model = message.get("model") + if message.get('role') == 'assistant': + model = message.get('model') if model: if model not in models: models[model] = 0 @@ -340,21 +316,19 @@ def get_message_content_length(message): ) # Construct Chat Body - chat_body = ChatBody( - history=ChatHistoryStats(messages=export_messages, currentId=message_id) - ) + chat_body = ChatBody(history=ChatHistoryStats(messages=export_messages, currentId=message_id)) return ChatStatsExport( id=chat.id, user_id=chat.user_id, created_at=chat.created_at, updated_at=chat.updated_at, - tags=chat.meta.get("tags", []), + tags=chat.meta.get('tags', []), stats=stats, chat=chat_body, ) except Exception as e: - log.exception(f"Error exporting stats for chat {chat.id}: {e}") + log.exception(f'Error exporting stats for chat {chat.id}: {e}') return None @@ -408,14 +382,14 @@ def generate_chat_stats_jsonl_generator(user_id, filter): try: chat_stat = _process_chat_for_export(chat) if chat_stat: - yield chat_stat.model_dump_json() + "\n" + yield chat_stat.model_dump_json() + '\n' except Exception as e: - log.exception(f"Error processing chat {chat.id}: {e}") + log.exception(f'Error processing chat {chat.id}: {e}') skip += limit -@router.get("/stats/export", response_model=ChatStatsExportList) +@router.get('/stats/export', response_model=ChatStatsExportList) async def export_chat_stats( request: Request, updated_at: Optional[int] = None, @@ -424,9 +398,7 @@ async def export_chat_stats( user=Depends(get_verified_user), ): # Check if the user has permission to share/export chats - if (user.role != "admin") and ( - not request.app.state.config.ENABLE_COMMUNITY_SHARING - ): + if (user.role != 'admin') and (not request.app.state.config.ENABLE_COMMUNITY_SHARING): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -434,36 +406,28 @@ async def export_chat_stats( try: # Fetch chats with date filtering - filter = {"order_by": "updated_at", "direction": "asc"} + filter = {'order_by': 'updated_at', 'direction': 'asc'} if updated_at: - filter["updated_at"] = updated_at + filter['updated_at'] = updated_at if stream: return StreamingResponse( generate_chat_stats_jsonl_generator(user.id, filter), - media_type="application/x-ndjson", - headers={ - "Content-Disposition": f"attachment; filename=chat-stats-export-{user.id}.jsonl" - }, + media_type='application/x-ndjson', + headers={'Content-Disposition': f'attachment; filename=chat-stats-export-{user.id}.jsonl'}, ) else: limit = CHAT_EXPORT_PAGE_ITEM_COUNT skip = (page - 1) * limit - chat_stats_export_list, total = await asyncio.to_thread( - calculate_chat_stats, user.id, skip, limit, filter - ) + chat_stats_export_list, total = await asyncio.to_thread(calculate_chat_stats, user.id, skip, limit, filter) - return ChatStatsExportList( - items=chat_stats_export_list, total=total, page=page - ) + return ChatStatsExportList(items=chat_stats_export_list, total=total, page=page) except Exception as e: - log.debug(f"Error exporting chat stats: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + log.debug(f'Error exporting chat stats: {e}') + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -471,7 +435,7 @@ async def export_chat_stats( ############################ -@router.get("/stats/export/{chat_id}", response_model=Optional[ChatStatsExport]) +@router.get('/stats/export/{chat_id}', response_model=Optional[ChatStatsExport]) async def export_single_chat_stats( request: Request, chat_id: str, @@ -483,9 +447,7 @@ async def export_single_chat_stats( Returns ChatStatsExport for the specified chat. """ # Check if the user has permission to share/export chats - if (user.role != "admin") and ( - not request.app.state.config.ENABLE_COMMUNITY_SHARING - ): + if (user.role != 'admin') and (not request.app.state.config.ENABLE_COMMUNITY_SHARING): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -501,7 +463,7 @@ async def export_single_chat_stats( ) # Verify the chat belongs to the user (unless admin) - if chat.user_id != user.id and user.role != "admin": + if chat.user_id != user.id and user.role != 'admin': raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -513,7 +475,7 @@ async def export_single_chat_stats( if not chat_stats: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to process chat stats", + detail='Failed to process chat stats', ) return chat_stats @@ -521,22 +483,17 @@ async def export_single_chat_stats( except HTTPException: raise except Exception as e: - log.debug(f"Error exporting single chat stats: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + log.debug(f'Error exporting single chat stats: {e}') + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) -@router.delete("/", response_model=bool) +@router.delete('/', response_model=bool) async def delete_all_user_chats( request: Request, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - - if user.role == "user" and not has_permission( - user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS - ): + if user.role == 'user' and not has_permission(user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -551,7 +508,7 @@ async def delete_all_user_chats( ############################ -@router.get("/list/user/{user_id}", response_model=list[ChatTitleIdResponse]) +@router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse]) async def get_user_chat_list_by_user_id( user_id: str, page: Optional[int] = None, @@ -575,15 +532,13 @@ async def get_user_chat_list_by_user_id( filter = {} if query: - filter["query"] = query + filter['query'] = query if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction - return Chats.get_chat_list_by_user_id( - user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db - ) + return Chats.get_chat_list_by_user_id(user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db) ############################ @@ -591,7 +546,7 @@ async def get_user_chat_list_by_user_id( ############################ -@router.post("/new", response_model=Optional[ChatResponse]) +@router.post('/new', response_model=Optional[ChatResponse]) async def create_new_chat( form_data: ChatForm, user=Depends(get_verified_user), @@ -602,9 +557,7 @@ async def create_new_chat( return ChatResponse(**chat.model_dump()) except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -612,7 +565,7 @@ async def create_new_chat( ############################ -@router.post("/import", response_model=list[ChatResponse]) +@router.post('/import', response_model=list[ChatResponse]) async def import_chats( form_data: ChatsImportForm, user=Depends(get_verified_user), @@ -623,9 +576,7 @@ async def import_chats( return chats except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -633,7 +584,7 @@ async def import_chats( ############################ -@router.get("/search", response_model=list[ChatTitleIdResponse]) +@router.get('/search', response_model=list[ChatTitleIdResponse]) def search_user_chats( text: str, page: Optional[int] = None, @@ -648,18 +599,16 @@ def search_user_chats( chat_list = [ ChatTitleIdResponse(**chat.model_dump()) - for chat in Chats.get_chats_by_user_id_and_search_text( - user.id, text, skip=skip, limit=limit, db=db - ) + for chat in Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db) ] # Delete tag if no chat is found - words = text.strip().split(" ") - if page == 1 and len(words) == 1 and words[0].startswith("tag:"): - tag_id = words[0].replace("tag:", "") + words = text.strip().split(' ') + if page == 1 and len(words) == 1 and words[0].startswith('tag:'): + tag_id = words[0].replace('tag:', '') if len(chat_list) == 0: if Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db): - log.debug(f"deleting tag: {tag_id}") + log.debug(f'deleting tag: {tag_id}') Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db) return chat_list @@ -670,26 +619,20 @@ def search_user_chats( ############################ -@router.get("/folder/{folder_id}", response_model=list[ChatResponse]) -async def get_chats_by_folder_id( - folder_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/folder/{folder_id}', response_model=list[ChatResponse]) +async def get_chats_by_folder_id(folder_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): folder_ids = [folder_id] - children_folders = Folders.get_children_folders_by_id_and_user_id( - folder_id, user.id, db=db - ) + children_folders = Folders.get_children_folders_by_id_and_user_id(folder_id, user.id, db=db) if children_folders: folder_ids.extend([folder.id for folder in children_folders]) return [ ChatResponse(**chat.model_dump()) - for chat in Chats.get_chats_by_folder_ids_and_user_id( - folder_ids, user.id, db=db - ) + for chat in 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') async def get_chat_list_by_folder_id( folder_id: str, page: Optional[int] = 1, @@ -701,17 +644,13 @@ async def get_chat_list_by_folder_id( skip = (page - 1) * limit return [ - {"title": chat.title, "id": chat.id, "updated_at": chat.updated_at} - for chat in Chats.get_chats_by_folder_id_and_user_id( - folder_id, user.id, skip=skip, limit=limit, db=db - ) + {'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at} + for chat in Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db) ] except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -719,10 +658,8 @@ 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: Session = Depends(get_session) -): +@router.get('/pinned', response_model=list[ChatTitleIdResponse]) +async def get_user_pinned_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): return Chats.get_pinned_chats_by_user_id(user.id, db=db) @@ -731,10 +668,8 @@ async def get_user_pinned_chats( ############################ -@router.get("/all", response_model=list[ChatResponse]) -async def get_user_chats( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/all', response_model=list[ChatResponse]) +async def get_user_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): result = Chats.get_chats_by_user_id(user.id, db=db) return [ChatResponse(**chat.model_dump()) for chat in result.items] @@ -744,14 +679,9 @@ async def get_user_chats( ############################ -@router.get("/all/archived", response_model=list[ChatResponse]) -async def get_user_archived_chats( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): - return [ - ChatResponse(**chat.model_dump()) - for chat in Chats.get_archived_chats_by_user_id(user.id, db=db) - ] +@router.get('/all/archived', response_model=list[ChatResponse]) +async def get_user_archived_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): + return [ChatResponse(**chat.model_dump()) for chat in Chats.get_archived_chats_by_user_id(user.id, db=db)] ############################ @@ -759,18 +689,14 @@ async def get_user_archived_chats( ############################ -@router.get("/all/tags", response_model=list[TagModel]) -async def get_all_user_tags( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/all/tags', response_model=list[TagModel]) +async def get_all_user_tags(user=Depends(get_verified_user), db: Session = Depends(get_session)): try: tags = Tags.get_tags_by_user_id(user.id, db=db) return tags except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -778,10 +704,8 @@ async def get_all_user_tags( ############################ -@router.get("/all/db", response_model=list[ChatResponse]) -async def get_all_user_chats_in_db( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/all/db', response_model=list[ChatResponse]) +async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: Session = Depends(get_session)): if not ENABLE_ADMIN_EXPORT: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -795,7 +719,7 @@ async def get_all_user_chats_in_db( ############################ -@router.get("/archived", response_model=list[ChatTitleIdResponse]) +@router.get('/archived', response_model=list[ChatTitleIdResponse]) async def get_archived_session_user_chat_list( page: Optional[int] = None, query: Optional[str] = None, @@ -812,11 +736,11 @@ async def get_archived_session_user_chat_list( filter = {} if query: - filter["query"] = query + filter['query'] = query if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction return Chats.get_archived_chat_list_by_user_id( user.id, @@ -832,10 +756,8 @@ async def get_archived_session_user_chat_list( ############################ -@router.post("/archive/all", response_model=bool) -async def archive_all_chats( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.post('/archive/all', response_model=bool) +async def archive_all_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): return Chats.archive_all_chats_by_user_id(user.id, db=db) @@ -844,10 +766,8 @@ async def archive_all_chats( ############################ -@router.post("/unarchive/all", response_model=bool) -async def unarchive_all_chats( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.post('/unarchive/all', response_model=bool) +async def unarchive_all_chats(user=Depends(get_verified_user), db: Session = Depends(get_session)): return Chats.unarchive_all_chats_by_user_id(user.id, db=db) @@ -856,7 +776,7 @@ async def unarchive_all_chats( ############################ -@router.get("/shared", response_model=list[SharedChatResponse]) +@router.get('/shared', response_model=list[SharedChatResponse]) async def get_shared_session_user_chat_list( page: Optional[int] = None, query: Optional[str] = None, @@ -873,11 +793,11 @@ async def get_shared_session_user_chat_list( filter = {} if query: - filter["query"] = query + filter['query'] = query if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction return Chats.get_shared_chat_list_by_user_id( user.id, @@ -893,27 +813,21 @@ async def get_shared_session_user_chat_list( ############################ -@router.get("/share/{share_id}", response_model=Optional[ChatResponse]) -async def get_shared_chat_by_id( - share_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): - if user.role == "pending": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND - ) +@router.get('/share/{share_id}', response_model=Optional[ChatResponse]) +async def get_shared_chat_by_id(share_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): + if user.role == 'pending': + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role == "user" or (user.role == "admin" and not ENABLE_ADMIN_CHAT_ACCESS): + if user.role == 'user' or (user.role == 'admin' and not ENABLE_ADMIN_CHAT_ACCESS): chat = Chats.get_chat_by_share_id(share_id, db=db) - elif user.role == "admin" and ENABLE_ADMIN_CHAT_ACCESS: + elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS: chat = Chats.get_chat_by_id(share_id, db=db) if chat: return ChatResponse(**chat.model_dump()) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -930,15 +844,13 @@ class TagFilterForm(TagForm): limit: Optional[int] = 50 -@router.post("/tags", response_model=list[ChatTitleIdResponse]) +@router.post('/tags', response_model=list[ChatTitleIdResponse]) async def get_user_chat_list_by_tag_name( form_data: TagFilterForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - chats = Chats.get_chat_list_by_user_id_and_tag_name( - user.id, form_data.name, form_data.skip, form_data.limit, db=db - ) + chats = Chats.get_chat_list_by_user_id_and_tag_name(user.id, form_data.name, form_data.skip, form_data.limit, db=db) if len(chats) == 0: Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) @@ -950,19 +862,15 @@ async def get_user_chat_list_by_tag_name( ############################ -@router.get("/{id}", response_model=Optional[ChatResponse]) -async def get_chat_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}', response_model=Optional[ChatResponse]) +async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: return ChatResponse(**chat.model_dump()) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -970,7 +878,7 @@ async def get_chat_by_id( ############################ -@router.post("/{id}", response_model=Optional[ChatResponse]) +@router.post('/{id}', response_model=Optional[ChatResponse]) async def update_chat_by_id( id: str, form_data: ChatForm, @@ -996,7 +904,7 @@ class MessageForm(BaseModel): content: str -@router.post("/{id}/messages/{message_id}", response_model=Optional[ChatResponse]) +@router.post('/{id}/messages/{message_id}', response_model=Optional[ChatResponse]) async def update_chat_message_by_id( id: str, message_id: str, @@ -1012,7 +920,7 @@ async def update_chat_message_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if chat.user_id != user.id and user.role != "admin": + if chat.user_id != user.id and user.role != 'admin': raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1022,16 +930,15 @@ async def update_chat_message_by_id( id, message_id, { - "content": form_data.content, + 'content': form_data.content, }, - db=db, ) event_emitter = get_event_emitter( { - "user_id": user.id, - "chat_id": id, - "message_id": message_id, + 'user_id': user.id, + 'chat_id': id, + 'message_id': message_id, }, False, ) @@ -1039,11 +946,11 @@ async def update_chat_message_by_id( if event_emitter: await event_emitter( { - "type": "chat:message", - "data": { - "chat_id": id, - "message_id": message_id, - "content": form_data.content, + 'type': 'chat:message', + 'data': { + 'chat_id': id, + 'message_id': message_id, + 'content': form_data.content, }, } ) @@ -1059,7 +966,7 @@ class EventForm(BaseModel): data: dict -@router.post("/{id}/messages/{message_id}/event", response_model=Optional[bool]) +@router.post('/{id}/messages/{message_id}/event', response_model=Optional[bool]) async def send_chat_message_event_by_id( id: str, message_id: str, @@ -1075,7 +982,7 @@ async def send_chat_message_event_by_id( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if chat.user_id != user.id and user.role != "admin": + if chat.user_id != user.id and user.role != 'admin': raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1083,9 +990,9 @@ async def send_chat_message_event_by_id( event_emitter = get_event_emitter( { - "user_id": user.id, - "chat_id": id, - "message_id": message_id, + 'user_id': user.id, + 'chat_id': id, + 'message_id': message_id, } ) @@ -1095,7 +1002,7 @@ async def send_chat_message_event_by_id( else: return False return True - except: + except Exception: return False @@ -1104,31 +1011,27 @@ async def send_chat_message_event_by_id( ############################ -@router.delete("/{id}", response_model=bool) +@router.delete('/{id}', response_model=bool) async def delete_chat_by_id( request: Request, id: str, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role == "admin": + if user.role == 'admin': chat = 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, ) - Chats.delete_orphan_tags_for_user( - chat.meta.get("tags", []), user.id, threshold=1, db=db - ) + Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) result = Chats.delete_chat_by_id(id, db=db) return result else: - if not has_permission( - user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS - ): + if not has_permission(user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -1140,9 +1043,7 @@ async def delete_chat_by_id( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - Chats.delete_orphan_tags_for_user( - chat.meta.get("tags", []), user.id, threshold=1, db=db - ) + Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db) result = Chats.delete_chat_by_id_and_user_id(id, user.id, db=db) return result @@ -1153,17 +1054,13 @@ async def delete_chat_by_id( ############################ -@router.get("/{id}/pinned", response_model=Optional[bool]) -async def get_pinned_status_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}/pinned', response_model=Optional[bool]) +async def get_pinned_status_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: return chat.pinned else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1171,18 +1068,14 @@ async def get_pinned_status_by_id( ############################ -@router.post("/{id}/pin", response_model=Optional[ChatResponse]) -async def pin_chat_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.post('/{id}/pin', response_model=Optional[ChatResponse]) +async def pin_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: chat = Chats.toggle_chat_pinned_by_id(id, db=db) return chat else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1194,7 +1087,7 @@ class CloneForm(BaseModel): title: Optional[str] = None -@router.post("/{id}/clone", response_model=Optional[ChatResponse]) +@router.post('/{id}/clone', response_model=Optional[ChatResponse]) async def clone_chat_by_id( form_data: CloneForm, id: str, @@ -1205,9 +1098,9 @@ async def clone_chat_by_id( if chat: updated_chat = { **chat.chat, - "originalChatId": chat.id, - "branchPointMessageId": chat.chat["history"]["currentId"], - "title": form_data.title if form_data.title else f"Clone of {chat.title}", + 'originalChatId': chat.id, + 'branchPointMessageId': chat.chat['history']['currentId'], + 'title': form_data.title if form_data.title else f'Clone of {chat.title}', } chats = Chats.import_chats( @@ -1215,10 +1108,10 @@ async def clone_chat_by_id( [ ChatImportForm( **{ - "chat": updated_chat, - "meta": chat.meta, - "pinned": chat.pinned, - "folder_id": chat.folder_id, + 'chat': updated_chat, + 'meta': chat.meta, + 'pinned': chat.pinned, + 'folder_id': chat.folder_id, } ) ], @@ -1234,9 +1127,7 @@ async def clone_chat_by_id( detail=ERROR_MESSAGES.DEFAULT(), ) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1244,12 +1135,9 @@ async def clone_chat_by_id( ############################ -@router.post("/{id}/clone/shared", response_model=Optional[ChatResponse]) -async def clone_shared_chat_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): - - if user.role == "admin": +@router.post('/{id}/clone/shared', response_model=Optional[ChatResponse]) +async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): + if user.role == 'admin': chat = Chats.get_chat_by_id(id, db=db) else: chat = Chats.get_chat_by_share_id(id, db=db) @@ -1257,9 +1145,9 @@ async def clone_shared_chat_by_id( if chat: updated_chat = { **chat.chat, - "originalChatId": chat.id, - "branchPointMessageId": chat.chat["history"]["currentId"], - "title": f"Clone of {chat.title}", + 'originalChatId': chat.id, + 'branchPointMessageId': chat.chat['history']['currentId'], + 'title': f'Clone of {chat.title}', } chats = Chats.import_chats( @@ -1267,10 +1155,10 @@ async def clone_shared_chat_by_id( [ ChatImportForm( **{ - "chat": updated_chat, - "meta": chat.meta, - "pinned": chat.pinned, - "folder_id": chat.folder_id, + 'chat': updated_chat, + 'meta': chat.meta, + 'pinned': chat.pinned, + 'folder_id': chat.folder_id, } ) ], @@ -1286,9 +1174,7 @@ async def clone_shared_chat_by_id( detail=ERROR_MESSAGES.DEFAULT(), ) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1296,15 +1182,13 @@ async def clone_shared_chat_by_id( ############################ -@router.post("/{id}/archive", response_model=Optional[ChatResponse]) -async def archive_chat_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.post('/{id}/archive', response_model=Optional[ChatResponse]) +async def archive_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: chat = Chats.toggle_chat_archive_by_id(id, db=db) - tag_ids = chat.meta.get("tags", []) + tag_ids = chat.meta.get('tags', []) if chat.archived: # Archived chats are excluded from count โ€” clean up orphans Chats.delete_orphan_tags_for_user(tag_ids, user.id, db=db) @@ -1314,9 +1198,7 @@ async def archive_chat_by_id( return ChatResponse(**chat.model_dump()) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1324,17 +1206,15 @@ async def archive_chat_by_id( ############################ -@router.post("/{id}/share", response_model=Optional[ChatResponse]) +@router.post('/{id}/share', response_model=Optional[ChatResponse]) async def share_chat_by_id( request: Request, id: str, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if (user.role != "admin") and ( - not has_permission( - user.id, "chat.share", request.app.state.config.USER_PERMISSIONS - ) + if (user.role != 'admin') and ( + not has_permission(user.id, 'chat.share', request.app.state.config.USER_PERMISSIONS) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -1368,10 +1248,8 @@ async def share_chat_by_id( ############################ -@router.delete("/{id}/share", response_model=Optional[bool]) -async def delete_shared_chat_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.delete('/{id}/share', response_model=Optional[bool]) +async def delete_shared_chat_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: if not chat.share_id: @@ -1397,7 +1275,7 @@ class ChatFolderIdForm(BaseModel): folder_id: Optional[str] = None -@router.post("/{id}/folder", response_model=Optional[ChatResponse]) +@router.post('/{id}/folder', response_model=Optional[ChatResponse]) async def update_chat_folder_id_by_id( id: str, form_data: ChatFolderIdForm, @@ -1406,14 +1284,10 @@ async def update_chat_folder_id_by_id( ): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - chat = Chats.update_chat_folder_id_by_id_and_user_id( - id, user.id, form_data.folder_id, db=db - ) + chat = Chats.update_chat_folder_id_by_id_and_user_id(id, user.id, form_data.folder_id, db=db) return ChatResponse(**chat.model_dump()) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1421,18 +1295,14 @@ async def update_chat_folder_id_by_id( ############################ -@router.get("/{id}/tags", response_model=list[TagModel]) -async def get_chat_tags_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}/tags', response_model=list[TagModel]) +async def get_chat_tags_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - tags = chat.meta.get("tags", []) + tags = chat.meta.get('tags', []) return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -1440,7 +1310,7 @@ async def get_chat_tags_by_id( ############################ -@router.post("/{id}/tags", response_model=list[TagModel]) +@router.post('/{id}/tags', response_model=list[TagModel]) async def add_tag_by_id_and_tag_name( id: str, form_data: TagForm, @@ -1449,27 +1319,23 @@ async def add_tag_by_id_and_tag_name( ): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - tags = chat.meta.get("tags", []) - tag_id = form_data.name.replace(" ", "_").lower() + tags = chat.meta.get('tags', []) + tag_id = form_data.name.replace(' ', '_').lower() - if tag_id == "none": + if tag_id == 'none': raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT("Tag name cannot be 'None'"), ) if tag_id not in tags: - Chats.add_chat_tag_by_id_and_user_id_and_tag_name( - id, user.id, form_data.name, db=db - ) + Chats.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name, db=db) chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) - tags = chat.meta.get("tags", []) + tags = chat.meta.get('tags', []) return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -1477,7 +1343,7 @@ async def add_tag_by_id_and_tag_name( ############################ -@router.delete("/{id}/tags", response_model=list[TagModel]) +@router.delete('/{id}/tags', response_model=list[TagModel]) async def delete_tag_by_id_and_tag_name( id: str, form_data: TagForm, @@ -1486,23 +1352,16 @@ async def delete_tag_by_id_and_tag_name( ): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - Chats.delete_tag_by_id_and_user_id_and_tag_name( - id, user.id, form_data.name, db=db - ) + Chats.delete_tag_by_id_and_user_id_and_tag_name(id, user.id, form_data.name, db=db) - if ( - Chats.count_chats_by_tag_name_and_user_id(form_data.name, user.id, db=db) - == 0 - ): + if Chats.count_chats_by_tag_name_and_user_id(form_data.name, user.id, db=db) == 0: Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db) chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) - tags = chat.meta.get("tags", []) + tags = chat.meta.get('tags', []) return Tags.get_tags_by_ids_and_user_id(tags, user.id, db=db) else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) ############################ @@ -1510,18 +1369,14 @@ async def delete_tag_by_id_and_tag_name( ############################ -@router.delete("/{id}/tags/all", response_model=Optional[bool]) -async def delete_all_tags_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.delete('/{id}/tags/all', response_model=Optional[bool]) +async def delete_all_tags_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: - old_tags = chat.meta.get("tags", []) + old_tags = chat.meta.get('tags', []) Chats.delete_all_tags_by_id_and_user_id(id, user.id, db=db) Chats.delete_orphan_tags_for_user(old_tags, user.id, db=db) return True else: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND) diff --git a/backend/open_webui/routers/configs.py b/backend/open_webui/routers/configs.py index a51b6ff8d8..6d09602c5c 100644 --- a/backend/open_webui/routers/configs.py +++ b/backend/open_webui/routers/configs.py @@ -24,6 +24,7 @@ from open_webui.utils.oauth import ( get_discovery_urls, get_oauth_client_info_with_dynamic_client_registration, + get_oauth_client_info_with_static_credentials, encrypt_data, decrypt_data, OAuthClientInformationFull, @@ -37,6 +38,8 @@ ############################ # ImportConfig +# Thy configuration come, thy settings be done, +# in production as it is in development. ############################ @@ -44,7 +47,7 @@ class ImportConfigForm(BaseModel): config: dict -@router.post("/import", response_model=dict) +@router.post('/import', response_model=dict) async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user)): save_config(form_data.config) return get_config() @@ -55,7 +58,7 @@ async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user ############################ -@router.get("/export", response_model=dict) +@router.get('/export', response_model=dict) async def export_config(user=Depends(get_admin_user)): return get_config() @@ -70,30 +73,26 @@ class ConnectionsConfigForm(BaseModel): ENABLE_BASE_MODELS_CACHE: bool -@router.get("/connections", response_model=ConnectionsConfigForm) +@router.get('/connections', response_model=ConnectionsConfigForm) async def get_connections_config(request: Request, user=Depends(get_admin_user)): return { - "ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS, - "ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE, + 'ENABLE_DIRECT_CONNECTIONS': request.app.state.config.ENABLE_DIRECT_CONNECTIONS, + 'ENABLE_BASE_MODELS_CACHE': request.app.state.config.ENABLE_BASE_MODELS_CACHE, } -@router.post("/connections", response_model=ConnectionsConfigForm) +@router.post('/connections', response_model=ConnectionsConfigForm) async def set_connections_config( request: Request, form_data: ConnectionsConfigForm, user=Depends(get_admin_user), ): - request.app.state.config.ENABLE_DIRECT_CONNECTIONS = ( - form_data.ENABLE_DIRECT_CONNECTIONS - ) - request.app.state.config.ENABLE_BASE_MODELS_CACHE = ( - form_data.ENABLE_BASE_MODELS_CACHE - ) + request.app.state.config.ENABLE_DIRECT_CONNECTIONS = form_data.ENABLE_DIRECT_CONNECTIONS + request.app.state.config.ENABLE_BASE_MODELS_CACHE = form_data.ENABLE_BASE_MODELS_CACHE return { - "ENABLE_DIRECT_CONNECTIONS": request.app.state.config.ENABLE_DIRECT_CONNECTIONS, - "ENABLE_BASE_MODELS_CACHE": request.app.state.config.ENABLE_BASE_MODELS_CACHE, + 'ENABLE_DIRECT_CONNECTIONS': request.app.state.config.ENABLE_DIRECT_CONNECTIONS, + 'ENABLE_BASE_MODELS_CACHE': request.app.state.config.ENABLE_BASE_MODELS_CACHE, } @@ -101,9 +100,10 @@ class OAuthClientRegistrationForm(BaseModel): url: str client_id: str client_name: Optional[str] = None + client_secret: Optional[str] = None -@router.post("/oauth/clients/register") +@router.post('/oauth/clients/register') async def register_oauth_client( request: Request, form_data: OAuthClientRegistrationForm, @@ -113,24 +113,30 @@ async def register_oauth_client( try: oauth_client_id = form_data.client_id if type: - oauth_client_id = f"{type}:{form_data.client_id}" - - oauth_client_info = ( - await get_oauth_client_info_with_dynamic_client_registration( + oauth_client_id = f'{type}:{form_data.client_id}' + + if form_data.client_secret: + # Static credentials: skip dynamic registration, build from provided credentials + oauth_client_info = await get_oauth_client_info_with_static_credentials( + request, + oauth_client_id, + form_data.url, + oauth_client_id=form_data.client_id, + oauth_client_secret=form_data.client_secret, + ) + else: + oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration( request, oauth_client_id, form_data.url ) - ) return { - "status": True, - "oauth_client_info": encrypt_data( - oauth_client_info.model_dump(mode="json") - ), + 'status': True, + 'oauth_client_info': encrypt_data(oauth_client_info.model_dump(mode='json')), } except Exception as e: - log.debug(f"Failed to register OAuth client: {e}") + log.debug(f'Failed to register OAuth client: {e}') raise HTTPException( status_code=400, - detail=f"Failed to register OAuth client", + detail=f'Failed to register OAuth client', ) @@ -142,44 +148,44 @@ async def register_oauth_client( class ToolServerConnection(BaseModel): url: str path: str - type: Optional[str] = "openapi" # openapi, mcp + type: Optional[str] = 'openapi' # openapi, mcp auth_type: Optional[str] headers: Optional[dict | str] = None key: Optional[str] config: Optional[dict] - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class ToolServersConfigForm(BaseModel): TOOL_SERVER_CONNECTIONS: list[ToolServerConnection] -@router.get("/tool_servers", response_model=ToolServersConfigForm) +@router.get('/tool_servers', response_model=ToolServersConfigForm) async def get_tool_servers_config(request: Request, user=Depends(get_admin_user)): return { - "TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS, + 'TOOL_SERVER_CONNECTIONS': request.app.state.config.TOOL_SERVER_CONNECTIONS, } -@router.post("/tool_servers", response_model=ToolServersConfigForm) +@router.post('/tool_servers', response_model=ToolServersConfigForm) async def set_tool_servers_config( request: Request, form_data: ToolServersConfigForm, user=Depends(get_admin_user), ): for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS: - server_type = connection.get("type", "openapi") - auth_type = connection.get("auth_type", "none") + server_type = connection.get('type', 'openapi') + auth_type = connection.get('auth_type', 'none') - if auth_type == "oauth_2.1": + if auth_type in ('oauth_2.1', 'oauth_2.1_static'): # Remove existing OAuth clients for tool servers - server_id = connection.get("info", {}).get("id") - client_key = f"{server_type}:{server_id}" + server_id = connection.get('info', {}).get('id') + client_key = f'{server_type}:{server_id}' try: request.app.state.oauth_client_manager.remove_client(client_key) - except: + except Exception: pass # Set new tool server connections @@ -190,60 +196,63 @@ async def set_tool_servers_config( await set_tool_servers(request) for connection in request.app.state.config.TOOL_SERVER_CONNECTIONS: - server_type = connection.get("type", "openapi") - if server_type == "mcp": - server_id = connection.get("info", {}).get("id") - auth_type = connection.get("auth_type", "none") + server_type = connection.get('type', 'openapi') + if server_type == 'mcp': + server_id = connection.get('info', {}).get('id') + auth_type = connection.get('auth_type', 'none') - if auth_type == "oauth_2.1" and server_id: + if auth_type in ('oauth_2.1', 'oauth_2.1_static') and server_id: try: - oauth_client_info = connection.get("info", {}).get( - "oauth_client_info", "" - ) + oauth_client_info = connection.get('info', {}).get('oauth_client_info', '') oauth_client_info = decrypt_data(oauth_client_info) request.app.state.oauth_client_manager.add_client( - f"{server_type}:{server_id}", + f'{server_type}:{server_id}', OAuthClientInformationFull(**oauth_client_info), ) except Exception as e: - log.debug(f"Failed to add OAuth client for MCP tool server: {e}") + log.debug(f'Failed to add OAuth client for MCP tool server: {e}') continue return { - "TOOL_SERVER_CONNECTIONS": request.app.state.config.TOOL_SERVER_CONNECTIONS, + 'TOOL_SERVER_CONNECTIONS': request.app.state.config.TOOL_SERVER_CONNECTIONS, } class TerminalServerConnection(BaseModel): - id: Optional[str] = "" - name: Optional[str] = "" + id: Optional[str] = '' + name: Optional[str] = '' enabled: Optional[bool] = True url: str - path: Optional[str] = "/openapi.json" + path: Optional[str] = '/openapi.json' - key: Optional[str] = "" - auth_type: Optional[str] = "bearer" + key: Optional[str] = '' + auth_type: Optional[str] = 'bearer' config: Optional[dict] = None - model_config = ConfigDict(extra="allow") + # Orchestrator policy fields + server_type: Optional[str] = None # "orchestrator", "terminal" + policy_id: Optional[str] = None + policy: Optional[dict] = None # cached policy data + + model_config = ConfigDict(extra='allow') class TerminalServersConfigForm(BaseModel): TERMINAL_SERVER_CONNECTIONS: list[TerminalServerConnection] -@router.get("/terminal_servers") +@router.get('/terminal_servers') async def get_terminal_servers_config(request: Request, user=Depends(get_admin_user)): return { - "TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS, + 'TERMINAL_SERVER_CONNECTIONS': request.app.state.config.TERMINAL_SERVER_CONNECTIONS, } -@router.post("/terminal_servers") +@router.post('/terminal_servers') async def set_terminal_servers_config( request: Request, form_data: TerminalServersConfigForm, @@ -256,57 +265,45 @@ async def set_terminal_servers_config( await set_terminal_servers(request) return { - "TERMINAL_SERVER_CONNECTIONS": request.app.state.config.TERMINAL_SERVER_CONNECTIONS, + 'TERMINAL_SERVER_CONNECTIONS': request.app.state.config.TERMINAL_SERVER_CONNECTIONS, } -@router.post("/tool_servers/verify") -async def verify_tool_servers_config( - request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user) -): +@router.post('/tool_servers/verify') +async def verify_tool_servers_config(request: Request, form_data: ToolServerConnection, user=Depends(get_admin_user)): """ Verify the connection to the tool server. """ try: - if form_data.type == "mcp": - if form_data.auth_type == "oauth_2.1": + if form_data.type == 'mcp': + if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'): discovery_urls = await get_discovery_urls(form_data.url) for discovery_url in discovery_urls: - log.debug( - f"Trying to fetch OAuth 2.1 discovery document from {discovery_url}" - ) + log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}') async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) as session: - async with session.get( - discovery_url - ) as oauth_server_metadata_response: + async with session.get(discovery_url) as oauth_server_metadata_response: if oauth_server_metadata_response.status == 200: try: - oauth_server_metadata = ( - OAuthMetadata.model_validate( - await oauth_server_metadata_response.json() - ) + oauth_server_metadata = OAuthMetadata.model_validate( + await oauth_server_metadata_response.json() ) return { - "status": True, - "oauth_server_metadata": oauth_server_metadata.model_dump( - mode="json" - ), + 'status': True, + 'oauth_server_metadata': oauth_server_metadata.model_dump(mode='json'), } except Exception as e: - log.info( - f"Failed to parse OAuth 2.1 discovery document: {e}" - ) + log.info(f'Failed to parse OAuth 2.1 discovery document: {e}') raise HTTPException( status_code=400, - detail=f"Failed to parse OAuth 2.1 discovery document from {discovery_url}", + detail=f'Failed to parse OAuth 2.1 discovery document from {discovery_url}', ) raise HTTPException( status_code=400, - detail=f"Failed to fetch OAuth 2.1 discovery document from {discovery_urls}", + detail=f'Failed to fetch OAuth 2.1 discovery document from {discovery_urls}', ) else: try: @@ -314,25 +311,25 @@ async def verify_tool_servers_config( headers = None token = None - if form_data.auth_type == "bearer": + if form_data.auth_type == 'bearer': token = form_data.key - elif form_data.auth_type == "session": + elif form_data.auth_type == 'session': token = request.state.token.credentials - elif form_data.auth_type == "system_oauth": + elif form_data.auth_type == 'system_oauth': oauth_token = None try: - if request.cookies.get("oauth_session_id", None): + if request.cookies.get('oauth_session_id', None): oauth_token = await request.app.state.oauth_manager.get_oauth_token( user.id, - request.cookies.get("oauth_session_id", None), + request.cookies.get('oauth_session_id', None), ) if oauth_token: - token = oauth_token.get("access_token", "") + token = oauth_token.get('access_token', '') except Exception as e: pass if token: - headers = {"Authorization": f"Bearer {token}"} + headers = {'Authorization': f'Bearer {token}'} if form_data.headers and isinstance(form_data.headers, dict): if headers is None: @@ -342,14 +339,14 @@ async def verify_tool_servers_config( await client.connect(form_data.url, headers=headers) specs = await client.list_tool_specs() return { - "status": True, - "specs": specs, + 'status': True, + 'specs': specs, } except Exception as e: - log.debug(f"Failed to create MCP client: {e}") + log.debug(f'Failed to create MCP client: {e}') raise HTTPException( status_code=400, - detail=f"Failed to create MCP client", + detail=f'Failed to create MCP client', ) finally: if client: @@ -357,28 +354,26 @@ async def verify_tool_servers_config( else: # openapi token = None headers = None - if form_data.auth_type == "bearer": + if form_data.auth_type == 'bearer': token = form_data.key - elif form_data.auth_type == "session": + elif form_data.auth_type == 'session': token = request.state.token.credentials - elif form_data.auth_type == "system_oauth": + elif form_data.auth_type == 'system_oauth': try: - if request.cookies.get("oauth_session_id", None): - oauth_token = ( - await request.app.state.oauth_manager.get_oauth_token( - user.id, - request.cookies.get("oauth_session_id", None), - ) + if request.cookies.get('oauth_session_id', None): + oauth_token = await request.app.state.oauth_manager.get_oauth_token( + user.id, + request.cookies.get('oauth_session_id', None), ) if oauth_token: - token = oauth_token.get("access_token", "") + token = oauth_token.get('access_token', '') except Exception as e: pass if token: - headers = {"Authorization": f"Bearer {token}"} + headers = {'Authorization': f'Bearer {token}'} if form_data.headers and isinstance(form_data.headers, dict): if headers is None: @@ -390,10 +385,10 @@ async def verify_tool_servers_config( except HTTPException as e: raise e except Exception as e: - log.debug(f"Failed to connect to the tool server: {e}") + log.debug(f'Failed to connect to the tool server: {e}') raise HTTPException( status_code=400, - detail=f"Failed to connect to the tool server", + detail=f'Failed to connect to the tool server', ) @@ -418,90 +413,68 @@ class CodeInterpreterConfigForm(BaseModel): CODE_INTERPRETER_JUPYTER_TIMEOUT: Optional[int] -@router.get("/code_execution", response_model=CodeInterpreterConfigForm) +@router.get('/code_execution', response_model=CodeInterpreterConfigForm) async def get_code_execution_config(request: Request, user=Depends(get_admin_user)): return { - "ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION, - "CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE, - "CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL, - "CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH, - "CODE_EXECUTION_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN, - "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, - "CODE_EXECUTION_JUPYTER_TIMEOUT": request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, - "ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER, - "CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE, - "CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, - "CODE_INTERPRETER_JUPYTER_URL": request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, - "CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, - "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, - "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, - "CODE_INTERPRETER_JUPYTER_TIMEOUT": request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, + 'ENABLE_CODE_EXECUTION': request.app.state.config.ENABLE_CODE_EXECUTION, + 'CODE_EXECUTION_ENGINE': request.app.state.config.CODE_EXECUTION_ENGINE, + 'CODE_EXECUTION_JUPYTER_URL': request.app.state.config.CODE_EXECUTION_JUPYTER_URL, + 'CODE_EXECUTION_JUPYTER_AUTH': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH, + 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN, + 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, + 'CODE_EXECUTION_JUPYTER_TIMEOUT': request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, + 'ENABLE_CODE_INTERPRETER': request.app.state.config.ENABLE_CODE_INTERPRETER, + 'CODE_INTERPRETER_ENGINE': request.app.state.config.CODE_INTERPRETER_ENGINE, + 'CODE_INTERPRETER_PROMPT_TEMPLATE': request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, + 'CODE_INTERPRETER_JUPYTER_URL': request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, + 'CODE_INTERPRETER_JUPYTER_AUTH': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, + 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, + 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, + 'CODE_INTERPRETER_JUPYTER_TIMEOUT': request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, } -@router.post("/code_execution", response_model=CodeInterpreterConfigForm) +@router.post('/code_execution', response_model=CodeInterpreterConfigForm) async def set_code_execution_config( request: Request, form_data: CodeInterpreterConfigForm, user=Depends(get_admin_user) ): request.app.state.config.ENABLE_CODE_EXECUTION = form_data.ENABLE_CODE_EXECUTION request.app.state.config.CODE_EXECUTION_ENGINE = form_data.CODE_EXECUTION_ENGINE - request.app.state.config.CODE_EXECUTION_JUPYTER_URL = ( - form_data.CODE_EXECUTION_JUPYTER_URL - ) - request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH = ( - form_data.CODE_EXECUTION_JUPYTER_AUTH - ) - request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = ( - form_data.CODE_EXECUTION_JUPYTER_AUTH_TOKEN - ) - request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = ( - form_data.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD - ) - request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = ( - form_data.CODE_EXECUTION_JUPYTER_TIMEOUT - ) + request.app.state.config.CODE_EXECUTION_JUPYTER_URL = form_data.CODE_EXECUTION_JUPYTER_URL + request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH = form_data.CODE_EXECUTION_JUPYTER_AUTH + request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN = form_data.CODE_EXECUTION_JUPYTER_AUTH_TOKEN + request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD = form_data.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD + request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT = form_data.CODE_EXECUTION_JUPYTER_TIMEOUT request.app.state.config.ENABLE_CODE_INTERPRETER = form_data.ENABLE_CODE_INTERPRETER request.app.state.config.CODE_INTERPRETER_ENGINE = form_data.CODE_INTERPRETER_ENGINE - request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = ( - form_data.CODE_INTERPRETER_PROMPT_TEMPLATE - ) + request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE = form_data.CODE_INTERPRETER_PROMPT_TEMPLATE - request.app.state.config.CODE_INTERPRETER_JUPYTER_URL = ( - form_data.CODE_INTERPRETER_JUPYTER_URL - ) + request.app.state.config.CODE_INTERPRETER_JUPYTER_URL = form_data.CODE_INTERPRETER_JUPYTER_URL - request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = ( - form_data.CODE_INTERPRETER_JUPYTER_AUTH - ) + request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH = form_data.CODE_INTERPRETER_JUPYTER_AUTH - request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = ( - form_data.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN - ) - request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = ( - form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD - ) - request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = ( - form_data.CODE_INTERPRETER_JUPYTER_TIMEOUT - ) + request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN = form_data.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN + request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD = form_data.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD + request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT = form_data.CODE_INTERPRETER_JUPYTER_TIMEOUT return { - "ENABLE_CODE_EXECUTION": request.app.state.config.ENABLE_CODE_EXECUTION, - "CODE_EXECUTION_ENGINE": request.app.state.config.CODE_EXECUTION_ENGINE, - "CODE_EXECUTION_JUPYTER_URL": request.app.state.config.CODE_EXECUTION_JUPYTER_URL, - "CODE_EXECUTION_JUPYTER_AUTH": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH, - "CODE_EXECUTION_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN, - "CODE_EXECUTION_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, - "CODE_EXECUTION_JUPYTER_TIMEOUT": request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, - "ENABLE_CODE_INTERPRETER": request.app.state.config.ENABLE_CODE_INTERPRETER, - "CODE_INTERPRETER_ENGINE": request.app.state.config.CODE_INTERPRETER_ENGINE, - "CODE_INTERPRETER_PROMPT_TEMPLATE": request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, - "CODE_INTERPRETER_JUPYTER_URL": request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, - "CODE_INTERPRETER_JUPYTER_AUTH": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, - "CODE_INTERPRETER_JUPYTER_AUTH_TOKEN": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, - "CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD": request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, - "CODE_INTERPRETER_JUPYTER_TIMEOUT": request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, + 'ENABLE_CODE_EXECUTION': request.app.state.config.ENABLE_CODE_EXECUTION, + 'CODE_EXECUTION_ENGINE': request.app.state.config.CODE_EXECUTION_ENGINE, + 'CODE_EXECUTION_JUPYTER_URL': request.app.state.config.CODE_EXECUTION_JUPYTER_URL, + 'CODE_EXECUTION_JUPYTER_AUTH': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH, + 'CODE_EXECUTION_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN, + 'CODE_EXECUTION_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD, + 'CODE_EXECUTION_JUPYTER_TIMEOUT': request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, + 'ENABLE_CODE_INTERPRETER': request.app.state.config.ENABLE_CODE_INTERPRETER, + 'CODE_INTERPRETER_ENGINE': request.app.state.config.CODE_INTERPRETER_ENGINE, + 'CODE_INTERPRETER_PROMPT_TEMPLATE': request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE, + 'CODE_INTERPRETER_JUPYTER_URL': request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, + 'CODE_INTERPRETER_JUPYTER_AUTH': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH, + 'CODE_INTERPRETER_JUPYTER_AUTH_TOKEN': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN, + 'CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD': request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD, + 'CODE_INTERPRETER_JUPYTER_TIMEOUT': request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, } @@ -516,32 +489,37 @@ class ModelsConfigForm(BaseModel): DEFAULT_MODEL_PARAMS: Optional[dict] = None -@router.get("/models", response_model=ModelsConfigForm) +@router.get('/models/defaults') +async def get_models_defaults(request: Request, user=Depends(get_verified_user)): + return { + 'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA, + } + + +@router.get('/models', response_model=ModelsConfigForm) async def get_models_config(request: Request, user=Depends(get_admin_user)): return { - "DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS, - "DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS, - "MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST, - "DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA, - "DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS, + 'DEFAULT_MODELS': request.app.state.config.DEFAULT_MODELS, + 'DEFAULT_PINNED_MODELS': request.app.state.config.DEFAULT_PINNED_MODELS, + 'MODEL_ORDER_LIST': request.app.state.config.MODEL_ORDER_LIST, + 'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA, + 'DEFAULT_MODEL_PARAMS': request.app.state.config.DEFAULT_MODEL_PARAMS, } -@router.post("/models", response_model=ModelsConfigForm) -async def set_models_config( - request: Request, form_data: ModelsConfigForm, user=Depends(get_admin_user) -): +@router.post('/models', response_model=ModelsConfigForm) +async def set_models_config(request: Request, form_data: ModelsConfigForm, user=Depends(get_admin_user)): request.app.state.config.DEFAULT_MODELS = form_data.DEFAULT_MODELS request.app.state.config.DEFAULT_PINNED_MODELS = form_data.DEFAULT_PINNED_MODELS request.app.state.config.MODEL_ORDER_LIST = form_data.MODEL_ORDER_LIST request.app.state.config.DEFAULT_MODEL_METADATA = form_data.DEFAULT_MODEL_METADATA request.app.state.config.DEFAULT_MODEL_PARAMS = form_data.DEFAULT_MODEL_PARAMS return { - "DEFAULT_MODELS": request.app.state.config.DEFAULT_MODELS, - "DEFAULT_PINNED_MODELS": request.app.state.config.DEFAULT_PINNED_MODELS, - "MODEL_ORDER_LIST": request.app.state.config.MODEL_ORDER_LIST, - "DEFAULT_MODEL_METADATA": request.app.state.config.DEFAULT_MODEL_METADATA, - "DEFAULT_MODEL_PARAMS": request.app.state.config.DEFAULT_MODEL_PARAMS, + 'DEFAULT_MODELS': request.app.state.config.DEFAULT_MODELS, + 'DEFAULT_PINNED_MODELS': request.app.state.config.DEFAULT_PINNED_MODELS, + 'MODEL_ORDER_LIST': request.app.state.config.MODEL_ORDER_LIST, + 'DEFAULT_MODEL_METADATA': request.app.state.config.DEFAULT_MODEL_METADATA, + 'DEFAULT_MODEL_PARAMS': request.app.state.config.DEFAULT_MODEL_PARAMS, } @@ -554,14 +532,14 @@ class SetDefaultSuggestionsForm(BaseModel): suggestions: list[PromptSuggestion] -@router.post("/suggestions", response_model=list[PromptSuggestion]) +@router.post('/suggestions', response_model=list[PromptSuggestion]) async def set_default_suggestions( request: Request, form_data: SetDefaultSuggestionsForm, user=Depends(get_admin_user), ): data = form_data.model_dump() - request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data["suggestions"] + request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS = data['suggestions'] return request.app.state.config.DEFAULT_PROMPT_SUGGESTIONS @@ -574,18 +552,18 @@ class SetBannersForm(BaseModel): banners: list[BannerModel] -@router.post("/banners", response_model=list[BannerModel]) +@router.post('/banners', response_model=list[BannerModel]) async def set_banners( request: Request, form_data: SetBannersForm, user=Depends(get_admin_user), ): data = form_data.model_dump() - request.app.state.config.BANNERS = data["banners"] + request.app.state.config.BANNERS = data['banners'] return request.app.state.config.BANNERS -@router.get("/banners", response_model=list[BannerModel]) +@router.get('/banners', response_model=list[BannerModel]) async def get_banners( request: Request, user=Depends(get_verified_user), @@ -600,19 +578,19 @@ async def get_banners( class UsageConfigForm(BaseModel): CREDIT_NO_CHARGE_EMPTY_RESPONSE: bool = Field(default=False) - CREDIT_NO_CREDIT_MSG: str = Field(default="ไฝ™้ขไธ่ถณ๏ผŒ่ฏทๅ‰ๅพ€ ่ฎพ็ฝฎ-็งฏๅˆ† ๅ……ๅ€ผ") + CREDIT_NO_CREDIT_MSG: str = Field(default='ไฝ™้ขไธ่ถณ๏ผŒ่ฏทๅ‰ๅพ€ ่ฎพ็ฝฎ-็งฏๅˆ† ๅ……ๅ€ผ') CREDIT_EXCHANGE_RATIO: float = Field(default=1, gt=0) CREDIT_DEFAULT_CREDIT: float = Field(default=0, ge=0) - USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE: str = Field(default="") - USAGE_DEFAULT_ENCODING_MODEL: str = Field(default="gpt-4o") + USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE: str = Field(default='') + USAGE_DEFAULT_ENCODING_MODEL: str = Field(default='gpt-4o') USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE: float = Field(default=0, ge=0) USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE: float = Field(default=0, ge=0) USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE: float = Field(default=0, ge=0) USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE: float = Field(default=0, ge=0) USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE: float = Field(default=0, ge=0) USAGE_CALCULATE_MINIMUM_COST: float = Field(default=0, ge=0) - USAGE_CUSTOM_PRICE_CONFIG: str = Field(default="[]") - EZFP_PAY_PRIORITY: Literal["qrcode", "link"] = Field(default="qrcode") + USAGE_CUSTOM_PRICE_CONFIG: str = Field(default='[]') + EZFP_PAY_PRIORITY: Literal['qrcode', 'link'] = Field(default='qrcode') EZFP_ENDPOINT: Optional[str] = None EZFP_PID: Optional[str] = None EZFP_KEY: Optional[str] = None @@ -627,60 +605,48 @@ class UsageConfigForm(BaseModel): ALIPAY_PRODUCT_CODE: Optional[str] = None -@router.get("/usage", response_model=UsageConfigForm) +@router.get('/usage', response_model=UsageConfigForm) async def get_usage_config(request: Request, _=Depends(get_admin_user)): return { - "CREDIT_NO_CHARGE_EMPTY_RESPONSE": request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE, - "CREDIT_NO_CREDIT_MSG": request.app.state.config.CREDIT_NO_CREDIT_MSG, - "CREDIT_EXCHANGE_RATIO": request.app.state.config.CREDIT_EXCHANGE_RATIO, - "CREDIT_DEFAULT_CREDIT": request.app.state.config.CREDIT_DEFAULT_CREDIT, - "USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE": request.app.state.config.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE, - "USAGE_DEFAULT_ENCODING_MODEL": request.app.state.config.USAGE_DEFAULT_ENCODING_MODEL, - "USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE": request.app.state.config.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE, - "USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE": request.app.state.config.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE, - "USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE": request.app.state.config.USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE, - "USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE": request.app.state.config.USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE, - "USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE": request.app.state.config.USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE, - "USAGE_CALCULATE_MINIMUM_COST": request.app.state.config.USAGE_CALCULATE_MINIMUM_COST, - "USAGE_CUSTOM_PRICE_CONFIG": request.app.state.config.USAGE_CUSTOM_PRICE_CONFIG, - "EZFP_PAY_PRIORITY": request.app.state.config.EZFP_PAY_PRIORITY, - "EZFP_ENDPOINT": request.app.state.config.EZFP_ENDPOINT, - "EZFP_PID": request.app.state.config.EZFP_PID, - "EZFP_KEY": request.app.state.config.EZFP_KEY, - "EZFP_CALLBACK_HOST": request.app.state.config.EZFP_CALLBACK_HOST, - "EZFP_AMOUNT_CONTROL": request.app.state.config.EZFP_AMOUNT_CONTROL, - "ALIPAY_SERVER_URL": request.app.state.config.ALIPAY_SERVER_URL, - "ALIPAY_APP_ID": request.app.state.config.ALIPAY_APP_ID, - "ALIPAY_APP_PRIVATE_KEY": request.app.state.config.ALIPAY_APP_PRIVATE_KEY, - "ALIPAY_ALIPAY_PUBLIC_KEY": request.app.state.config.ALIPAY_ALIPAY_PUBLIC_KEY, - "ALIPAY_CALLBACK_HOST": request.app.state.config.ALIPAY_CALLBACK_HOST, - "ALIPAY_AMOUNT_CONTROL": request.app.state.config.ALIPAY_AMOUNT_CONTROL, - "ALIPAY_PRODUCT_CODE": request.app.state.config.ALIPAY_PRODUCT_CODE, + 'CREDIT_NO_CHARGE_EMPTY_RESPONSE': request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE, + 'CREDIT_NO_CREDIT_MSG': request.app.state.config.CREDIT_NO_CREDIT_MSG, + 'CREDIT_EXCHANGE_RATIO': request.app.state.config.CREDIT_EXCHANGE_RATIO, + 'CREDIT_DEFAULT_CREDIT': request.app.state.config.CREDIT_DEFAULT_CREDIT, + 'USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE': request.app.state.config.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE, + 'USAGE_DEFAULT_ENCODING_MODEL': request.app.state.config.USAGE_DEFAULT_ENCODING_MODEL, + 'USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE': request.app.state.config.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE, + 'USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE': request.app.state.config.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE, + 'USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE': request.app.state.config.USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE, + 'USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE': request.app.state.config.USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE, + 'USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE': request.app.state.config.USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE, + 'USAGE_CALCULATE_MINIMUM_COST': request.app.state.config.USAGE_CALCULATE_MINIMUM_COST, + 'USAGE_CUSTOM_PRICE_CONFIG': request.app.state.config.USAGE_CUSTOM_PRICE_CONFIG, + 'EZFP_PAY_PRIORITY': request.app.state.config.EZFP_PAY_PRIORITY, + 'EZFP_ENDPOINT': request.app.state.config.EZFP_ENDPOINT, + 'EZFP_PID': request.app.state.config.EZFP_PID, + 'EZFP_KEY': request.app.state.config.EZFP_KEY, + 'EZFP_CALLBACK_HOST': request.app.state.config.EZFP_CALLBACK_HOST, + 'EZFP_AMOUNT_CONTROL': request.app.state.config.EZFP_AMOUNT_CONTROL, + 'ALIPAY_SERVER_URL': request.app.state.config.ALIPAY_SERVER_URL, + 'ALIPAY_APP_ID': request.app.state.config.ALIPAY_APP_ID, + 'ALIPAY_APP_PRIVATE_KEY': request.app.state.config.ALIPAY_APP_PRIVATE_KEY, + 'ALIPAY_ALIPAY_PUBLIC_KEY': request.app.state.config.ALIPAY_ALIPAY_PUBLIC_KEY, + 'ALIPAY_CALLBACK_HOST': request.app.state.config.ALIPAY_CALLBACK_HOST, + 'ALIPAY_AMOUNT_CONTROL': request.app.state.config.ALIPAY_AMOUNT_CONTROL, + 'ALIPAY_PRODUCT_CODE': request.app.state.config.ALIPAY_PRODUCT_CODE, } -@router.post("/usage", response_model=UsageConfigForm) -async def set_usage_config( - request: Request, form_data: UsageConfigForm, _=Depends(get_admin_user) -): - request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE = ( - form_data.CREDIT_NO_CHARGE_EMPTY_RESPONSE - ) +@router.post('/usage', response_model=UsageConfigForm) +async def set_usage_config(request: Request, form_data: UsageConfigForm, _=Depends(get_admin_user)): + request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE = form_data.CREDIT_NO_CHARGE_EMPTY_RESPONSE request.app.state.config.CREDIT_NO_CREDIT_MSG = form_data.CREDIT_NO_CREDIT_MSG request.app.state.config.CREDIT_EXCHANGE_RATIO = form_data.CREDIT_EXCHANGE_RATIO request.app.state.config.CREDIT_DEFAULT_CREDIT = form_data.CREDIT_DEFAULT_CREDIT - request.app.state.config.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE = ( - form_data.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE - ) - request.app.state.config.USAGE_DEFAULT_ENCODING_MODEL = ( - form_data.USAGE_DEFAULT_ENCODING_MODEL - ) - request.app.state.config.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE = ( - form_data.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE - ) - request.app.state.config.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE = ( - form_data.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE - ) + request.app.state.config.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE = form_data.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE + request.app.state.config.USAGE_DEFAULT_ENCODING_MODEL = form_data.USAGE_DEFAULT_ENCODING_MODEL + request.app.state.config.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE = form_data.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE + request.app.state.config.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE = form_data.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE request.app.state.config.USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE = ( form_data.USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE ) @@ -690,12 +656,8 @@ async def set_usage_config( request.app.state.config.USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE = ( form_data.USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE ) - request.app.state.config.USAGE_CALCULATE_MINIMUM_COST = ( - form_data.USAGE_CALCULATE_MINIMUM_COST - ) - request.app.state.config.USAGE_CUSTOM_PRICE_CONFIG = ( - form_data.USAGE_CUSTOM_PRICE_CONFIG - ) + request.app.state.config.USAGE_CALCULATE_MINIMUM_COST = form_data.USAGE_CALCULATE_MINIMUM_COST + request.app.state.config.USAGE_CUSTOM_PRICE_CONFIG = form_data.USAGE_CUSTOM_PRICE_CONFIG request.app.state.config.EZFP_PAY_PRIORITY = form_data.EZFP_PAY_PRIORITY request.app.state.config.EZFP_ENDPOINT = form_data.EZFP_ENDPOINT request.app.state.config.EZFP_PID = form_data.EZFP_PID @@ -705,38 +667,36 @@ async def set_usage_config( request.app.state.config.ALIPAY_SERVER_URL = form_data.ALIPAY_SERVER_URL request.app.state.config.ALIPAY_APP_ID = form_data.ALIPAY_APP_ID request.app.state.config.ALIPAY_APP_PRIVATE_KEY = form_data.ALIPAY_APP_PRIVATE_KEY - request.app.state.config.ALIPAY_ALIPAY_PUBLIC_KEY = ( - form_data.ALIPAY_ALIPAY_PUBLIC_KEY - ) + request.app.state.config.ALIPAY_ALIPAY_PUBLIC_KEY = form_data.ALIPAY_ALIPAY_PUBLIC_KEY request.app.state.config.ALIPAY_CALLBACK_HOST = form_data.ALIPAY_CALLBACK_HOST request.app.state.config.ALIPAY_AMOUNT_CONTROL = form_data.ALIPAY_AMOUNT_CONTROL request.app.state.config.ALIPAY_PRODUCT_CODE = form_data.ALIPAY_PRODUCT_CODE return { - "CREDIT_NO_CHARGE_EMPTY_RESPONSE": request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE, - "CREDIT_NO_CREDIT_MSG": request.app.state.config.CREDIT_NO_CREDIT_MSG, - "CREDIT_EXCHANGE_RATIO": request.app.state.config.CREDIT_EXCHANGE_RATIO, - "CREDIT_DEFAULT_CREDIT": request.app.state.config.CREDIT_DEFAULT_CREDIT, - "USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE": request.app.state.config.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE, - "USAGE_DEFAULT_ENCODING_MODEL": request.app.state.config.USAGE_DEFAULT_ENCODING_MODEL, - "USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE": request.app.state.config.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE, - "USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE": request.app.state.config.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE, - "USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE": request.app.state.config.USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE, - "USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE": request.app.state.config.USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE, - "USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE": request.app.state.config.USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE, - "USAGE_CALCULATE_MINIMUM_COST": request.app.state.config.USAGE_CALCULATE_MINIMUM_COST, - "USAGE_CUSTOM_PRICE_CONFIG": request.app.state.config.USAGE_CUSTOM_PRICE_CONFIG, - "EZFP_PAY_PRIORITY": request.app.state.config.EZFP_PAY_PRIORITY, - "EZFP_ENDPOINT": request.app.state.config.EZFP_ENDPOINT, - "EZFP_PID": request.app.state.config.EZFP_PID, - "EZFP_KEY": request.app.state.config.EZFP_KEY, - "EZFP_CALLBACK_HOST": request.app.state.config.EZFP_CALLBACK_HOST, - "EZFP_AMOUNT_CONTROL": request.app.state.config.EZFP_AMOUNT_CONTROL, - "ALIPAY_SERVER_URL": request.app.state.config.ALIPAY_SERVER_URL, - "ALIPAY_APP_ID": request.app.state.config.ALIPAY_APP_ID, - "ALIPAY_APP_PRIVATE_KEY": request.app.state.config.ALIPAY_APP_PRIVATE_KEY, - "ALIPAY_ALIPAY_PUBLIC_KEY": request.app.state.config.ALIPAY_ALIPAY_PUBLIC_KEY, - "ALIPAY_CALLBACK_HOST": request.app.state.config.ALIPAY_CALLBACK_HOST, - "ALIPAY_AMOUNT_CONTROL": request.app.state.config.ALIPAY_AMOUNT_CONTROL, - "ALIPAY_PRODUCT_CODE": request.app.state.config.ALIPAY_PRODUCT_CODE, + 'CREDIT_NO_CHARGE_EMPTY_RESPONSE': request.app.state.config.CREDIT_NO_CHARGE_EMPTY_RESPONSE, + 'CREDIT_NO_CREDIT_MSG': request.app.state.config.CREDIT_NO_CREDIT_MSG, + 'CREDIT_EXCHANGE_RATIO': request.app.state.config.CREDIT_EXCHANGE_RATIO, + 'CREDIT_DEFAULT_CREDIT': request.app.state.config.CREDIT_DEFAULT_CREDIT, + 'USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE': request.app.state.config.USAGE_CALCULATE_MODEL_PREFIX_TO_REMOVE, + 'USAGE_DEFAULT_ENCODING_MODEL': request.app.state.config.USAGE_DEFAULT_ENCODING_MODEL, + 'USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE': request.app.state.config.USAGE_CALCULATE_DEFAULT_EMBEDDING_PRICE, + 'USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE': request.app.state.config.USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE, + 'USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE': request.app.state.config.USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE, + 'USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE': request.app.state.config.USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE, + 'USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE': request.app.state.config.USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE, + 'USAGE_CALCULATE_MINIMUM_COST': request.app.state.config.USAGE_CALCULATE_MINIMUM_COST, + 'USAGE_CUSTOM_PRICE_CONFIG': request.app.state.config.USAGE_CUSTOM_PRICE_CONFIG, + 'EZFP_PAY_PRIORITY': request.app.state.config.EZFP_PAY_PRIORITY, + 'EZFP_ENDPOINT': request.app.state.config.EZFP_ENDPOINT, + 'EZFP_PID': request.app.state.config.EZFP_PID, + 'EZFP_KEY': request.app.state.config.EZFP_KEY, + 'EZFP_CALLBACK_HOST': request.app.state.config.EZFP_CALLBACK_HOST, + 'EZFP_AMOUNT_CONTROL': request.app.state.config.EZFP_AMOUNT_CONTROL, + 'ALIPAY_SERVER_URL': request.app.state.config.ALIPAY_SERVER_URL, + 'ALIPAY_APP_ID': request.app.state.config.ALIPAY_APP_ID, + 'ALIPAY_APP_PRIVATE_KEY': request.app.state.config.ALIPAY_APP_PRIVATE_KEY, + 'ALIPAY_ALIPAY_PUBLIC_KEY': request.app.state.config.ALIPAY_ALIPAY_PUBLIC_KEY, + 'ALIPAY_CALLBACK_HOST': request.app.state.config.ALIPAY_CALLBACK_HOST, + 'ALIPAY_AMOUNT_CONTROL': request.app.state.config.ALIPAY_AMOUNT_CONTROL, + 'ALIPAY_PRODUCT_CODE': request.app.state.config.ALIPAY_PRODUCT_CODE, } diff --git a/backend/open_webui/routers/credit.py b/backend/open_webui/routers/credit.py index 7a6e0be1b5..07e61f7bc2 100644 --- a/backend/open_webui/routers/credit.py +++ b/backend/open_webui/routers/credit.py @@ -44,24 +44,22 @@ PAGE_ITEM_COUNT = 30 -@router.get("/config") +@router.get('/config') async def get_config(request: Request): return { - "CREDIT_EXCHANGE_RATIO": request.app.state.config.CREDIT_EXCHANGE_RATIO, - "EZFP_PAY_PRIORITY": request.app.state.config.EZFP_PAY_PRIORITY, + 'CREDIT_EXCHANGE_RATIO': request.app.state.config.CREDIT_EXCHANGE_RATIO, + 'EZFP_PAY_PRIORITY': request.app.state.config.EZFP_PAY_PRIORITY, } -@router.get("/logs", response_model=list[CreditLogSimpleModel]) +@router.get('/logs', response_model=list[CreditLogSimpleModel]) async def list_credit_logs( page: Optional[int] = None, user: UserModel = Depends(get_verified_user) ) -> TradeTicketModel: if page: limit = PAGE_ITEM_COUNT offset = (page - 1) * limit - return CreditLogs.get_credit_log_by_page( - user_ids=[user.id], offset=offset, limit=limit - ) + return CreditLogs.get_credit_log_by_page(user_ids=[user.id], offset=offset, limit=limit) else: return CreditLogs.get_credit_log_by_page(user_ids=[user.id], offset=0, limit=10) @@ -74,16 +72,12 @@ class DeleteLogsResponse(BaseModel): affect_rows: int -@router.delete("/logs") -async def delete_credit_logs( - form_data: DeleteLogsForm, _: UserModel = Depends(get_admin_user) -) -> DeleteLogsResponse: - return DeleteLogsResponse( - affect_rows=CreditLogs.delete_log_by_timestamp(form_data.timestamp) - ) +@router.delete('/logs') +async def delete_credit_logs(form_data: DeleteLogsForm, _: UserModel = Depends(get_admin_user)) -> DeleteLogsResponse: + return DeleteLogsResponse(affect_rows=CreditLogs.delete_log_by_timestamp(form_data.timestamp)) -@router.get("/all_logs") +@router.get('/all_logs') async def get_all_logs( query: Optional[str] = None, page: Optional[int] = None, @@ -95,127 +89,115 @@ async def get_all_logs( limit = limit or PAGE_ITEM_COUNT offset = (page - 1) * limit # query users - users = Users.get_users(filter={"query": query}) - user_map = {user.id: user.name for user in users["users"]} + users = Users.get_users(filter={'query': query}) + user_map = {user.id: user.name for user in users['users']} if query and not user_map: - return {"total": 0, "results": []} + return {'total': 0, 'results': []} # query db user_ids = list(user_map.keys()) if query else None - results = CreditLogs.get_credit_log_by_page( - user_ids=user_ids, offset=offset, limit=limit - ) + results = CreditLogs.get_credit_log_by_page(user_ids=user_ids, offset=offset, limit=limit) total = CreditLogs.count_credit_log(user_ids=user_ids) # add username to results for result in results: - setattr(result, "username", user_map.get(result.user_id, "")) - return {"total": total, "results": results} + setattr(result, 'username', user_map.get(result.user_id, '')) + return {'total': total, 'results': results} -@router.post("/tickets", response_model=TradeTicketModel) +@router.post('/tickets', response_model=TradeTicketModel) async def create_ticket( request: Request, form_data: dict, user: UserModel = Depends(get_verified_user) ) -> TradeTicketModel: - out_trade_no = ( - f"{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex}" - ) - if form_data["pay_type"] == "alipay" and ALIPAY_APP_ID.value: - detail = await AlipayClient().create_trade( - out_trade_no=out_trade_no, amount=form_data["amount"] - ) + out_trade_no = f'{datetime.datetime.now().strftime("%Y%m%d%H%M%S")}_{uuid.uuid4().hex}' + if form_data['pay_type'] == 'alipay' and ALIPAY_APP_ID.value: + detail = await AlipayClient().create_trade(out_trade_no=out_trade_no, amount=form_data['amount']) else: detail = await ezfp_client.create_trade( - pay_type=form_data["pay_type"], + pay_type=form_data['pay_type'], out_trade_no=out_trade_no, - amount=form_data["amount"], + amount=form_data['amount'], client_ip=request.client.host, - ua=request.headers.get("User-Agent"), + ua=request.headers.get('User-Agent'), ) - return TradeTickets.insert_new_ticket( - id=out_trade_no, user_id=user.id, amount=form_data["amount"], detail=detail - ) + return TradeTickets.insert_new_ticket(id=out_trade_no, user_id=user.id, amount=form_data['amount'], detail=detail) -@router.get("/callback", response_class=PlainTextResponse) +@router.get('/callback', response_class=PlainTextResponse) async def ticket_callback(request: Request) -> str: callback = dict(request.query_params) - log.info("ezfp callback: %s", json.dumps(callback)) + log.info('ezfp callback: %s', json.dumps(callback)) if not ezfp_client.verify(callback): - return "invalid signature" + return 'invalid signature' # payment failed - if callback["trade_status"] != "TRADE_SUCCESS": - return "success" + if callback['trade_status'] != 'TRADE_SUCCESS': + return 'success' # find ticket - ticket = TradeTickets.get_ticket_by_id(callback["out_trade_no"]) + ticket = TradeTickets.get_ticket_by_id(callback['out_trade_no']) if not ticket: - return "no ticket fount" + return 'no ticket fount' # already callback - if ticket.detail.get("callback"): - return "success" + if ticket.detail.get('callback'): + return 'success' - ticket.detail["callback"] = callback + ticket.detail['callback'] = callback TradeTickets.update_credit_by_id(ticket.id, ticket.detail) - return "success" + return 'success' -@router.get("/callback/redirect", response_class=RedirectResponse) +@router.get('/callback/redirect', response_class=RedirectResponse) async def ticket_callback_redirect() -> RedirectResponse: return RedirectResponse(url=EZFP_CALLBACK_HOST.value, status_code=302) -@router.post("/callback/alipay", response_class=PlainTextResponse) +@router.post('/callback/alipay', response_class=PlainTextResponse) async def alipay_callback(request: Request) -> str: callback = dict(await request.form()) - log.info("alipay callback: %s", json.dumps(callback)) + log.info('alipay callback: %s', json.dumps(callback)) if not AlipayClient().verify(callback): - return "invalid signature" + return 'invalid signature' # payment failed - if callback["trade_status"] != "TRADE_SUCCESS": - return "success" + if callback['trade_status'] != 'TRADE_SUCCESS': + return 'success' # find ticket - ticket = TradeTickets.get_ticket_by_id(callback["out_trade_no"]) + ticket = TradeTickets.get_ticket_by_id(callback['out_trade_no']) if not ticket: - return "no ticket fount" + return 'no ticket fount' # already callback - if ticket.detail.get("callback"): - return "success" + if ticket.detail.get('callback'): + return 'success' - ticket.detail["callback"] = callback + ticket.detail['callback'] = callback TradeTickets.update_credit_by_id(ticket.id, ticket.detail) - return "success" + return 'success' -@router.get("/models/price") +@router.get('/models/price') async def get_model_price(request: Request, user: UserModel = Depends(get_admin_user)): # no info means not saved in db, which cannot be updated # preset model is always using base model's price return { - model["id"]: model.get("info", {}).get("price") or {} + model['id']: model.get('info', {}).get('price') or {} for model in await get_all_models(request, user) - if model.get("info") and not model.get("info", {}).get("base_model_id") + if model.get('info') and not model.get('info', {}).get('base_model_id') } -@router.put("/models/price") -async def update_model_price( - form_data: dict[str, dict], _: UserModel = Depends(get_admin_user) -): +@router.put('/models/price') +async def update_model_price(form_data: dict[str, dict], _: UserModel = Depends(get_admin_user)): for model_id, price in form_data.items(): model = Models.get_model_by_id(id=model_id) if not model: continue - model.price = ( - ModelPriceForm.model_validate(price).model_dump() if price else None - ) + model.price = ModelPriceForm.model_validate(price).model_dump() if price else None Models.update_model_by_id(id=model_id, model=model) - return f"success update price for {len(form_data)} models" + return f'success update price for {len(form_data)} models' class StatisticRequest(BaseModel): @@ -224,39 +206,33 @@ class StatisticRequest(BaseModel): query: Optional[str] = None -@router.post("/statistics") -async def get_statistics( - form_data: StatisticRequest, _: UserModel = Depends(get_admin_user) -): +@router.post('/statistics') +async def get_statistics(form_data: StatisticRequest, _: UserModel = Depends(get_admin_user)): # query user id user_ids = [] if form_data.query: - users = Users.get_users(filter={"query": form_data.query})["users"] + users = Users.get_users(filter={'query': form_data.query})['users'] user_map = {user.id: user.name for user in users} user_ids = list(user_map.keys()) if not user_ids: return { - "total_tokens": 0, - "total_credit": 0, - "model_cost_pie": [], - "model_token_pie": [], - "user_cost_pie": [], - "user_token_pie": [], - "total_payment": 0, - "user_payment_stats_x": [], - "user_payment_stats_y": [], + 'total_tokens': 0, + 'total_credit': 0, + 'model_cost_pie': [], + 'model_token_pie': [], + 'user_cost_pie': [], + 'user_token_pie': [], + 'total_payment': 0, + 'user_payment_stats_x': [], + 'user_payment_stats_y': [], } else: - users = Users.get_users()["users"] + users = Users.get_users()['users'] user_map = {user.id: user.name for user in users} # load credit data - logs = CreditLogs.get_log_by_time( - form_data.start_time, form_data.end_time, user_ids - ) - trade_logs = TradeTickets.get_ticket_by_time( - form_data.start_time, form_data.end_time, user_ids - ) + logs = CreditLogs.get_log_by_time(form_data.start_time, form_data.end_time, user_ids) + trade_logs = TradeTickets.get_ticket_by_time(form_data.start_time, form_data.end_time, user_ids) # build graph data total_tokens = 0 @@ -280,7 +256,7 @@ async def get_statistics( model_cost_pie[model_key] += log.detail.usage.total_price model_token_pie[model_key] += log.detail.usage.total_tokens - user_key = f"{log.user_id}:{user_map.get(log.user_id, log.user_id)}" + user_key = f'{log.user_id}:{user_map.get(log.user_id, log.user_id)}' user_cost_pie[user_key] += log.detail.usage.total_price user_token_pie[user_key] += log.detail.usage.total_tokens @@ -288,12 +264,12 @@ async def get_statistics( total_payment = 0 user_payment_data = defaultdict(Decimal) for log in trade_logs: - callback = log.detail.get("callback") + callback = log.detail.get('callback') if not callback: continue - if callback.get("trade_status") != "TRADE_SUCCESS": + if callback.get('trade_status') != 'TRADE_SUCCESS': continue - time_key = datetime.datetime.fromtimestamp(log.created_at).strftime("%Y-%m-%d") + time_key = datetime.datetime.fromtimestamp(log.created_at).strftime('%Y-%m-%d') user_payment_data[time_key] += log.amount total_payment += log.amount user_payment_stats_x = [] @@ -304,29 +280,19 @@ async def get_statistics( # response return { - "total_tokens": total_tokens, - "total_credit": total_credit, - "model_cost_pie": [ - {"name": model, "value": total} for model, total in model_cost_pie.items() - ], - "model_token_pie": [ - {"name": model, "value": total} for model, total in model_token_pie.items() - ], - "user_cost_pie": [ - {"name": user.split(":", 1)[1], "value": total} - for user, total in user_cost_pie.items() - ], - "user_token_pie": [ - {"name": user.split(":", 1)[1], "value": total} - for user, total in user_token_pie.items() - ], - "total_payment": total_payment, - "user_payment_stats_x": user_payment_stats_x, - "user_payment_stats_y": user_payment_stats_y, + 'total_tokens': total_tokens, + 'total_credit': total_credit, + 'model_cost_pie': [{'name': model, 'value': total} for model, total in model_cost_pie.items()], + 'model_token_pie': [{'name': model, 'value': total} for model, total in model_token_pie.items()], + 'user_cost_pie': [{'name': user.split(':', 1)[1], 'value': total} for user, total in user_cost_pie.items()], + 'user_token_pie': [{'name': user.split(':', 1)[1], 'value': total} for user, total in user_token_pie.items()], + 'total_payment': total_payment, + 'user_payment_stats_x': user_payment_stats_x, + 'user_payment_stats_y': user_payment_stats_y, } -@router.get("/redemption_codes") +@router.get('/redemption_codes') async def get_redemption_codes( keyword: Optional[str] = None, page: Optional[int] = None, @@ -345,18 +311,16 @@ async def get_redemption_codes( keyword = int(keyword) except (ValueError, TypeError): pass - total, codes = RedemptionCodes.get_codes( - keyword=keyword, offset=offset, limit=limit - ) + total, codes = RedemptionCodes.get_codes(keyword=keyword, offset=offset, limit=limit) if not codes: - return {"total": 0, "results": []} + return {'total': 0, 'results': []} # query users users = Users.get_users_by_user_ids(user_ids={code.user_id for code in codes}) user_map = {user.id: user.name for user in users} for code in codes: - setattr(code, "username", user_map.get(code.user_id, "")) + setattr(code, 'username', user_map.get(code.user_id, '')) # response - return {"total": total, "results": codes} + return {'total': total, 'results': codes} class CreateRedemptionCodeForm(BaseModel): @@ -366,34 +330,28 @@ class CreateRedemptionCodeForm(BaseModel): expired_at: Optional[int] = Field(default=None, gt=0) -@router.post("/redemption_codes") -async def create_redemption_code( - form_data: CreateRedemptionCodeForm, _: UserModel = Depends(get_admin_user) -) -> dict: +@router.post('/redemption_codes') +async def create_redemption_code(form_data: CreateRedemptionCodeForm, _: UserModel = Depends(get_admin_user)) -> dict: """ Create redemption codes """ # check redis _redis = get_redis_connection( redis_url=REDIS_URL, - redis_sentinels=get_sentinels_from_env( - REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT - ), + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), redis_cluster=REDIS_CLUSTER, ) if not _redis: - raise HTTPException(status_code=500, detail="Redis connection failed.") + raise HTTPException(status_code=500, detail='Redis connection failed.') # create now = int(time.time()) if form_data.expired_at is not None: expired_at = datetime.datetime.fromtimestamp(form_data.expired_at) if expired_at.timestamp() < now: - raise HTTPException( - status_code=400, detail="Expiration time must be in the future." - ) + raise HTTPException(status_code=400, detail='Expiration time must be in the future.') codes = [ RedemptionCodeModel( - code=f"{uuid.uuid4().hex}{uuid.uuid1().hex}", + code=f'{uuid.uuid4().hex}{uuid.uuid1().hex}', purpose=form_data.purpose, amount=Decimal(form_data.amount), created_at=now, @@ -402,7 +360,7 @@ async def create_redemption_code( for _ in range(form_data.count) ] RedemptionCodes.insert_codes(codes) - return {"total": len(codes)} + return {'total': len(codes)} class UpdateRedemptionCodeForm(BaseModel): @@ -411,7 +369,7 @@ class UpdateRedemptionCodeForm(BaseModel): expired_at: Optional[int] = Field(None, gt=0) -@router.put("/redemption_codes/{code}") +@router.put('/redemption_codes/{code}') async def update_redemption_code( code: str, form_data: UpdateRedemptionCodeForm, @@ -422,20 +380,18 @@ async def update_redemption_code( """ existing_code = RedemptionCodes.get_code(code) if not existing_code: - raise HTTPException(status_code=404, detail="Redemption code not found.") + raise HTTPException(status_code=404, detail='Redemption code not found.') if existing_code.received_at is not None: raise HTTPException( status_code=400, - detail="Cannot update a code that has already been received.", + detail='Cannot update a code that has already been received.', ) if form_data.expired_at is not None: expired_at = datetime.datetime.fromtimestamp(form_data.expired_at) if expired_at.timestamp() < int(time.time()): - raise HTTPException( - status_code=400, detail="Expiration time must be in the future." - ) + raise HTTPException(status_code=400, detail='Expiration time must be in the future.') existing_code.purpose = form_data.purpose existing_code.amount = Decimal(form_data.amount) @@ -444,68 +400,56 @@ async def update_redemption_code( return RedemptionCodes.update_code(existing_code) -@router.delete("/redemption_codes/{code}") -async def delete_redemption_codes( - code: str, _: UserModel = Depends(get_admin_user) -) -> None: +@router.delete('/redemption_codes/{code}') +async def delete_redemption_codes(code: str, _: UserModel = Depends(get_admin_user)) -> None: """ Delete a redemption code """ return RedemptionCodes.delete_code(code) -@router.get("/redemption_codes/export") -async def export_redemption_codes( - keyword: str, _: UserModel = Depends(get_admin_user) -) -> Response: +@router.get('/redemption_codes/export') +async def export_redemption_codes(keyword: str, _: UserModel = Depends(get_admin_user)) -> Response: """ Export all redemption codes as a plain text response. """ _, codes = RedemptionCodes.get_codes(keyword=keyword) # build CSV content - csv_content = "Code,Purpose,Amount,User ID,Created At,Expired At,Received At\n" + csv_content = 'Code,Purpose,Amount,User ID,Created At,Expired At,Received At\n' for code in codes: csv_content += ( - ",".join( + ','.join( [ code.code, f'"{code.purpose}"', str(code.amount), - str(code.user_id) if code.user_id else "", - datetime.datetime.fromtimestamp(code.created_at).strftime( - "%Y-%m-%d %H:%M:%S" - ), + str(code.user_id) if code.user_id else '', + datetime.datetime.fromtimestamp(code.created_at).strftime('%Y-%m-%d %H:%M:%S'), ( - datetime.datetime.fromtimestamp(code.expired_at).strftime( - "%Y-%m-%d %H:%M:%S" - ) + datetime.datetime.fromtimestamp(code.expired_at).strftime('%Y-%m-%d %H:%M:%S') if code.expired_at - else "" + else '' ), ( - datetime.datetime.fromtimestamp(code.received_at).strftime( - "%Y-%m-%d %H:%M:%S" - ) + datetime.datetime.fromtimestamp(code.received_at).strftime('%Y-%m-%d %H:%M:%S') if code.received_at - else "" + else '' ), ] ) - + "\n" + + '\n' ) # set the response headers headers = { - "Content-Disposition": f"attachment; filename={quote(keyword)}.csv", - "Content-Type": "text/csv", + 'Content-Disposition': f'attachment; filename={quote(keyword)}.csv', + 'Content-Type': 'text/csv', } # return the response return Response(content=csv_content, headers=headers) -@router.get("/redemption_codes/{code}/receive") -async def receive_redemption_code( - code: str, user: UserModel = Depends(get_verified_user) -) -> None: +@router.get('/redemption_codes/{code}/receive') +async def receive_redemption_code(code: str, user: UserModel = Depends(get_verified_user)) -> None: """ Receive a redemption code. """ diff --git a/backend/open_webui/routers/evaluations.py b/backend/open_webui/routers/evaluations.py index 22bb20df9b..de97e172f3 100644 --- a/backend/open_webui/routers/evaluations.py +++ b/backend/open_webui/routers/evaluations.py @@ -30,6 +30,8 @@ # Leaderboard Elo Rating Computation +# The judgment has already been rendered with grace; +# the scales have been balanced by a hand that never errs. # # How it works: # 1. Each model starts with a rating of 1000 @@ -51,9 +53,7 @@ import os -EMBEDDING_MODEL_NAME = os.environ.get( - "AUXILIARY_EMBEDDING_MODEL", "TaylorAI/bge-micro-v2" -) +EMBEDDING_MODEL_NAME = os.environ.get('AUXILIARY_EMBEDDING_MODEL', 'TaylorAI/bge-micro-v2') _embedding_model = None @@ -65,13 +65,11 @@ def _get_embedding_model(): _embedding_model = SentenceTransformer(EMBEDDING_MODEL_NAME) except Exception as e: - log.error(f"Embedding model load failed: {e}") + log.error(f'Embedding model load failed: {e}') return _embedding_model -def _calculate_elo( - feedbacks: list[LeaderboardFeedbackData], similarities: dict = None -) -> dict: +def _calculate_elo(feedbacks: list[LeaderboardFeedbackData], similarities: dict = None) -> dict: """ Calculate Elo ratings for models based on user feedback. @@ -90,35 +88,33 @@ def _calculate_elo( def get_or_create_stats(model_id): if model_id not in model_stats: - model_stats[model_id] = {"rating": 1000.0, "won": 0, "lost": 0} + model_stats[model_id] = {'rating': 1000.0, 'won': 0, 'lost': 0} return model_stats[model_id] for feedback in feedbacks: data = feedback.data or {} - winner_id = data.get("model_id") - rating_value = str(data.get("rating", "")) - if not winner_id or rating_value not in ("1", "-1"): + winner_id = data.get('model_id') + rating_value = str(data.get('rating', '')) + if not winner_id or rating_value not in ('1', '-1'): continue - won = rating_value == "1" + won = rating_value == '1' weight = similarities.get(feedback.id, 1.0) if similarities else 1.0 - for opponent_id in data.get("sibling_model_ids") or []: + for opponent_id in data.get('sibling_model_ids') or []: winner = get_or_create_stats(winner_id) opponent = get_or_create_stats(opponent_id) - expected = 1 / (1 + 10 ** ((opponent["rating"] - winner["rating"]) / 400)) + expected = 1 / (1 + 10 ** ((opponent['rating'] - winner['rating']) / 400)) - winner["rating"] += K_FACTOR * ((1 if won else 0) - expected) * weight - opponent["rating"] += ( - K_FACTOR * ((0 if won else 1) - (1 - expected)) * weight - ) + winner['rating'] += K_FACTOR * ((1 if won else 0) - expected) * weight + opponent['rating'] += K_FACTOR * ((0 if won else 1) - (1 - expected)) * weight if won: - winner["won"] += 1 - opponent["lost"] += 1 + winner['won'] += 1 + opponent['lost'] += 1 else: - winner["lost"] += 1 - opponent["won"] += 1 + winner['lost'] += 1 + opponent['won'] += 1 return model_stats @@ -139,16 +135,13 @@ def _get_top_tags(feedbacks: list[LeaderboardFeedbackData], limit: int = 5) -> d for feedback in feedbacks: data = feedback.data or {} - model_id = data.get("model_id") + model_id = data.get('model_id') if model_id: - for tag in data.get("tags", []): + for tag in data.get('tags', []): tag_counts[model_id][tag] += 1 return { - model_id: [ - {"tag": tag, "count": count} - for tag, count in sorted(tags.items(), key=lambda x: -x[1])[:limit] - ] + model_id: [{'tag': tag, 'count': count} for tag, count in sorted(tags.items(), key=lambda x: -x[1])[:limit]] for model_id, tags in tag_counts.items() } @@ -172,14 +165,7 @@ def _compute_similarities(feedbacks: list[LeaderboardFeedbackData], query: str) if not embedding_model: return {} - all_tags = list( - { - tag - for feedback in feedbacks - if feedback.data - for tag in feedback.data.get("tags", []) - } - ) + all_tags = list({tag for feedback in feedbacks if feedback.data for tag in feedback.data.get('tags', [])}) if not all_tags: return {} @@ -187,23 +173,18 @@ def _compute_similarities(feedbacks: list[LeaderboardFeedbackData], query: str) tag_embeddings = embedding_model.encode(all_tags) query_embedding = embedding_model.encode([query])[0] except Exception as e: - log.error(f"Embedding error: {e}") + log.error(f'Embedding error: {e}') return {} # Vectorized cosine similarity tag_norms = np.linalg.norm(tag_embeddings, axis=1) query_norm = np.linalg.norm(query_embedding) - similarities = np.dot(tag_embeddings, query_embedding) / ( - tag_norms * query_norm + 1e-9 - ) + similarities = np.dot(tag_embeddings, query_embedding) / (tag_norms * query_norm + 1e-9) tag_similarity_map = dict(zip(all_tags, similarities.tolist())) return { feedback.id: max( - ( - tag_similarity_map.get(tag, 0) - for tag in (feedback.data or {}).get("tags", []) - ), + (tag_similarity_map.get(tag, 0) for tag in (feedback.data or {}).get('tags', [])), default=0, ) for feedback in feedbacks @@ -223,7 +204,7 @@ class LeaderboardResponse(BaseModel): entries: list[LeaderboardEntry] -@router.get("/leaderboard", response_model=LeaderboardResponse) +@router.get('/leaderboard', response_model=LeaderboardResponse) async def get_leaderboard( query: Optional[str] = None, user=Depends(get_admin_user), @@ -234,9 +215,7 @@ async def get_leaderboard( similarities = None if query and query.strip(): - similarities = await run_in_threadpool( - _compute_similarities, feedbacks, query.strip() - ) + similarities = await run_in_threadpool(_compute_similarities, feedbacks, query.strip()) elo_stats = _calculate_elo(feedbacks, similarities) tags_by_model = _get_top_tags(feedbacks) @@ -245,10 +224,10 @@ async def get_leaderboard( [ LeaderboardEntry( model_id=mid, - rating=round(s["rating"]), - won=s["won"], - lost=s["lost"], - count=s["won"] + s["lost"], + rating=round(s['rating']), + won=s['won'], + lost=s['lost'], + count=s['won'] + s['lost'], top_tags=tags_by_model.get(mid, []), ) for mid, s in elo_stats.items() @@ -260,7 +239,7 @@ async def get_leaderboard( return LeaderboardResponse(entries=entries) -@router.get("/leaderboard/{model_id}/history", response_model=ModelHistoryResponse) +@router.get('/leaderboard/{model_id}/history', response_model=ModelHistoryResponse) async def get_model_history( model_id: str, days: int = 30, @@ -268,9 +247,7 @@ async def get_model_history( db: Session = Depends(get_session), ): """Get daily win/loss history for a specific model.""" - history = Feedbacks.get_model_evaluation_history( - model_id=model_id, days=days, db=db - ) + history = Feedbacks.get_model_evaluation_history(model_id=model_id, days=days, db=db) return ModelHistoryResponse(model_id=model_id, history=history) @@ -279,11 +256,11 @@ async def get_model_history( ############################ -@router.get("/config") +@router.get('/config') async def get_config(request: Request, user=Depends(get_admin_user)): return { - "ENABLE_EVALUATION_ARENA_MODELS": request.app.state.config.ENABLE_EVALUATION_ARENA_MODELS, - "EVALUATION_ARENA_MODELS": request.app.state.config.EVALUATION_ARENA_MODELS, + 'ENABLE_EVALUATION_ARENA_MODELS': request.app.state.config.ENABLE_EVALUATION_ARENA_MODELS, + 'EVALUATION_ARENA_MODELS': request.app.state.config.EVALUATION_ARENA_MODELS, } @@ -297,7 +274,7 @@ class UpdateConfigForm(BaseModel): EVALUATION_ARENA_MODELS: Optional[list[dict]] = None -@router.post("/config") +@router.post('/config') async def update_config( request: Request, form_data: UpdateConfigForm, @@ -309,54 +286,42 @@ async def update_config( if form_data.EVALUATION_ARENA_MODELS is not None: config.EVALUATION_ARENA_MODELS = form_data.EVALUATION_ARENA_MODELS return { - "ENABLE_EVALUATION_ARENA_MODELS": config.ENABLE_EVALUATION_ARENA_MODELS, - "EVALUATION_ARENA_MODELS": config.EVALUATION_ARENA_MODELS, + 'ENABLE_EVALUATION_ARENA_MODELS': config.ENABLE_EVALUATION_ARENA_MODELS, + 'EVALUATION_ARENA_MODELS': config.EVALUATION_ARENA_MODELS, } -@router.get("/feedbacks/all", response_model=list[FeedbackResponse]) -async def get_all_feedbacks( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/feedbacks/all', response_model=list[FeedbackResponse]) +async def get_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)): feedbacks = Feedbacks.get_all_feedbacks(db=db) return feedbacks -@router.get("/feedbacks/all/ids", response_model=list[FeedbackIdResponse]) -async def get_all_feedback_ids( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/feedbacks/all/ids', response_model=list[FeedbackIdResponse]) +async def get_all_feedback_ids(user=Depends(get_admin_user), db: Session = Depends(get_session)): return Feedbacks.get_all_feedback_ids(db=db) -@router.delete("/feedbacks/all") -async def delete_all_feedbacks( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.delete('/feedbacks/all') +async def delete_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)): success = Feedbacks.delete_all_feedbacks(db=db) return success -@router.get("/feedbacks/all/export", response_model=list[FeedbackModel]) -async def export_all_feedbacks( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/feedbacks/all/export', response_model=list[FeedbackModel]) +async def export_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)): feedbacks = Feedbacks.get_all_feedbacks(db=db) return feedbacks -@router.get("/feedbacks/user", response_model=list[FeedbackUserResponse]) -async def get_feedbacks( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/feedbacks/user', response_model=list[FeedbackUserResponse]) +async def get_feedbacks(user=Depends(get_verified_user), db: Session = Depends(get_session)): feedbacks = Feedbacks.get_feedbacks_by_user_id(user.id, db=db) return feedbacks -@router.delete("/feedbacks", response_model=bool) -async def delete_feedbacks( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.delete('/feedbacks', response_model=bool) +async def delete_feedbacks(user=Depends(get_verified_user), db: Session = Depends(get_session)): success = Feedbacks.delete_feedbacks_by_user_id(user.id, db=db) return success @@ -364,7 +329,7 @@ async def delete_feedbacks( PAGE_ITEM_COUNT = 30 -@router.get("/feedbacks/list", response_model=FeedbackListResponse) +@router.get('/feedbacks/list', response_model=FeedbackListResponse) async def get_feedbacks( order_by: Optional[str] = None, direction: Optional[str] = None, @@ -379,24 +344,22 @@ async def get_feedbacks( filter = {} if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db) return result -@router.post("/feedback", response_model=FeedbackModel) +@router.post('/feedback', response_model=FeedbackModel) async def create_feedback( request: Request, form_data: FeedbackForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - feedback = Feedbacks.insert_new_feedback( - user_id=user.id, form_data=form_data, db=db - ) + feedback = Feedbacks.insert_new_feedback(user_id=user.id, form_data=form_data, db=db) if not feedback: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -406,61 +369,45 @@ async def create_feedback( return feedback -@router.get("/feedback/{id}", response_model=FeedbackModel) -async def get_feedback_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): - if user.role == "admin": +@router.get('/feedback/{id}', response_model=FeedbackModel) +async def get_feedback_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): + if user.role == 'admin': feedback = Feedbacks.get_feedback_by_id(id=id, db=db) else: - feedback = Feedbacks.get_feedback_by_id_and_user_id( - id=id, user_id=user.id, db=db - ) + feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id, db=db) if not feedback: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) return feedback -@router.post("/feedback/{id}", response_model=FeedbackModel) +@router.post('/feedback/{id}', response_model=FeedbackModel) async def update_feedback_by_id( id: str, form_data: FeedbackForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role == "admin": + if user.role == 'admin': feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data, db=db) else: - feedback = Feedbacks.update_feedback_by_id_and_user_id( - id=id, user_id=user.id, form_data=form_data, db=db - ) + feedback = Feedbacks.update_feedback_by_id_and_user_id(id=id, user_id=user.id, form_data=form_data, db=db) if not feedback: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) return feedback -@router.delete("/feedback/{id}") -async def delete_feedback_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): - if user.role == "admin": +@router.delete('/feedback/{id}') +async def delete_feedback_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): + if user.role == 'admin': success = Feedbacks.delete_feedback_by_id(id=id, db=db) else: - success = Feedbacks.delete_feedback_by_id_and_user_id( - id=id, user_id=user.id, db=db - ) + success = Feedbacks.delete_feedback_by_id_and_user_id(id=id, user_id=user.id, db=db) if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) return success diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index 5268ea657d..6027545190 100644 --- a/backend/open_webui/routers/files.py +++ b/backend/open_webui/routers/files.py @@ -31,6 +31,7 @@ from open_webui.models.users import Users from open_webui.models.files import ( FileForm, + FileListResponse, FileModel, FileModelResponse, Files, @@ -61,6 +62,8 @@ ############################ # Upload File +# What was entrusted here was given in good faith. Let it +# be returned the same way, whole and undiminished. ############################ @@ -72,14 +75,14 @@ def _is_text_file(file_path: str, chunk_size: int = 8192) -> bool: """ try: resolved = Storage.get_file(file_path) - with open(resolved, "rb") as f: + with open(resolved, 'rb') as f: chunk = f.read(chunk_size) if not chunk: return False # Null bytes are a strong indicator of binary content - if b"\x00" in chunk: + if b'\x00' in chunk: return False - chunk.decode("utf-8") + chunk.decode('utf-8') return True except (UnicodeDecodeError, Exception): return False @@ -99,31 +102,25 @@ def _process_handler(db_session): content_type = file.content_type # Detect mis-labeled text files (e.g. .ts โ†’ video/mp2t) - if content_type and content_type.startswith(("image/", "video/")): + if content_type and content_type.startswith(('image/', 'video/')): if _is_text_file(file_path): - content_type = "text/plain" + content_type = 'text/plain' if content_type: - stt_supported_content_types = getattr( - request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", [] - ) + stt_supported_content_types = getattr(request.app.state.config, 'STT_SUPPORTED_CONTENT_TYPES', []) if strict_match_mime_type(stt_supported_content_types, content_type): file_path_processed = Storage.get_file(file_path) - result = transcribe( - request, file_path_processed, file_metadata, user - ) + result = transcribe(request, file_path_processed, file_metadata, user) process_file( request, - ProcessFileForm( - file_id=file_item.id, content=result.get("text", "") - ), + ProcessFileForm(file_id=file_item.id, content=result.get('text', '')), user=user, db=db_session, ) - elif (not content_type.startswith(("image/", "video/"))) or ( - request.app.state.config.CONTENT_EXTRACTION_ENGINE == "external" + elif (not content_type.startswith(('image/', 'video/'))) or ( + request.app.state.config.CONTENT_EXTRACTION_ENGINE == 'external' ): process_file( request, @@ -132,13 +129,9 @@ def _process_handler(db_session): db=db_session, ) else: - raise Exception( - f"File type {content_type} is not supported for processing" - ) + raise Exception(f'File type {content_type} is not supported for processing') else: - log.info( - f"File type {file.content_type} is not provided, but trying to process anyway" - ) + log.info(f'File type {file.content_type} is not provided, but trying to process anyway') process_file( request, ProcessFileForm(file_id=file_item.id), @@ -147,12 +140,12 @@ def _process_handler(db_session): ) except Exception as e: - log.error(f"Error processing file: {file_item.id}") + log.error(f'Error processing file: {file_item.id}') Files.update_file_data_by_id( file_item.id, { - "status": "failed", - "error": str(e.detail) if hasattr(e, "detail") else str(e), + 'status': 'failed', + 'error': str(e.detail) if hasattr(e, 'detail') else str(e), }, db=db_session, ) @@ -164,7 +157,7 @@ def _process_handler(db_session): _process_handler(db_session) -@router.post("/", response_model=FileModelResponse) +@router.post('/', response_model=FileModelResponse) def upload_file( request: Request, background_tasks: BackgroundTasks, @@ -197,7 +190,7 @@ def upload_file_handler( background_tasks: Optional[BackgroundTasks] = None, db: Optional[Session] = None, ): - log.info(f"file.content_type: {file.content_type} {process}") + log.info(f'file.content_type: {file.content_type} {process}') if isinstance(metadata, str): try: @@ -205,7 +198,7 @@ def upload_file_handler( except json.JSONDecodeError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Invalid metadata format"), + detail=ERROR_MESSAGES.DEFAULT('Invalid metadata format'), ) file_metadata = metadata if metadata else {} @@ -215,7 +208,7 @@ def upload_file_handler( file_extension = os.path.splitext(filename)[1] # Remove the leading dot from the file extension - file_extension = file_extension[1:] if file_extension else "" + file_extension = file_extension[1:] if file_extension else '' if process and request.app.state.config.ALLOWED_FILE_EXTENSIONS: request.app.state.config.ALLOWED_FILE_EXTENSIONS = [ @@ -225,23 +218,21 @@ def upload_file_handler( if file_extension not in request.app.state.config.ALLOWED_FILE_EXTENSIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT( - f"File type {file_extension} is not allowed" - ), + detail=ERROR_MESSAGES.DEFAULT(f'File type {file_extension} is not allowed'), ) # replace filename with uuid id = str(uuid.uuid4()) name = filename - filename = f"{id}_{filename}" + filename = f'{id}_{filename}' contents, file_path = Storage.upload_file( file.file, filename, { - "OpenWebUI-User-Email": user.email, - "OpenWebUI-User-Id": user.id, - "OpenWebUI-User-Name": user.name, - "OpenWebUI-File-Id": id, + 'OpenWebUI-User-Email': user.email, + 'OpenWebUI-User-Id': user.id, + 'OpenWebUI-User-Name': user.name, + 'OpenWebUI-File-Id': id, }, ) @@ -249,35 +240,27 @@ def upload_file_handler( user.id, FileForm( **{ - "id": id, - "filename": name, - "path": file_path, - "data": { - **({"status": "pending"} if process else {}), + 'id': id, + 'filename': name, + 'path': file_path, + 'data': { + **({'status': 'pending'} if process else {}), }, - "meta": { - "name": name, - "content_type": ( - file.content_type - if isinstance(file.content_type, str) - else None - ), - "size": len(contents), - "data": file_metadata, + 'meta': { + 'name': name, + 'content_type': (file.content_type if isinstance(file.content_type, str) else None), + 'size': len(contents), + 'data': file_metadata, }, } ), db=db, ) - if "channel_id" in file_metadata: - channel = Channels.get_channel_by_id_and_user_id( - file_metadata["channel_id"], user.id, db=db - ) + if 'channel_id' in file_metadata: + channel = Channels.get_channel_by_id_and_user_id(file_metadata['channel_id'], user.id, db=db) if channel: - Channels.add_file_to_channel_by_id( - channel.id, file_item.id, user.id, db=db - ) + Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id, db=db) if process: if background_tasks and process_in_background: @@ -290,7 +273,7 @@ def upload_file_handler( file_metadata, user, ) - return {"status": True, **file_item.model_dump()} + return {'status': True, **file_item.model_dump()} else: process_uploaded_file( request, @@ -301,14 +284,14 @@ def upload_file_handler( user, db=db, ) - return {"status": True, **file_item.model_dump()} + return {'status': True, **file_item.model_dump()} else: if file_item: return file_item else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error uploading file"), + detail=ERROR_MESSAGES.DEFAULT('Error uploading file'), ) except HTTPException as e: @@ -317,7 +300,7 @@ def upload_file_handler( log.exception(e) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error uploading file"), + detail=ERROR_MESSAGES.DEFAULT('Error uploading file'), ) @@ -326,23 +309,27 @@ def upload_file_handler( ############################ -@router.get("/", response_model=list[FileModelResponse]) +PAGE_SIZE = 50 + + +@router.get('/', response_model=FileListResponse) async def list_files( user=Depends(get_verified_user), + page: int = Query(1, ge=1, description='Page number (1-indexed)'), content: bool = Query(True), db: Session = Depends(get_session), ): - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: - files = Files.get_files(db=db) - else: - files = Files.get_files_by_user_id(user.id, db=db) + skip = (page - 1) * PAGE_SIZE + user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id + + result = Files.get_file_list(user_id=user_id, skip=skip, limit=PAGE_SIZE, db=db) if not content: - for file in files: - if "content" in file.data: - del file.data["content"] + for file in result.items: + if file.data and 'content' in file.data: + del file.data['content'] - return files + return result ############################ @@ -350,17 +337,15 @@ async def list_files( ############################ -@router.get("/search", response_model=list[FileModelResponse]) +@router.get('/search', response_model=list[FileModelResponse]) async def search_files( filename: str = Query( ..., description="Filename pattern to search for. Supports wildcards such as '*.txt'", ), content: bool = Query(True), - skip: int = Query(0, ge=0, description="Number of files to skip"), - limit: int = Query( - 100, ge=1, le=1000, description="Maximum number of files to return" - ), + skip: int = Query(0, ge=0, description='Number of files to skip'), + limit: int = Query(100, ge=1, le=1000, description='Maximum number of files to return'), user=Depends(get_verified_user), db: Session = Depends(get_session), ): @@ -369,9 +354,7 @@ async def search_files( Uses SQL-based filtering with pagination for better performance. """ # Determine user_id: null for admin with bypass (search all), user.id otherwise - user_id = ( - None if (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) else user.id - ) + user_id = None if (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) else user.id # Use optimized database query with pagination files = Files.search_files( @@ -385,13 +368,13 @@ async def search_files( if not files: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No files found matching the pattern.", + detail='No files found matching the pattern.', ) if not content: for file in files: - if file.data and "content" in file.data: - del file.data["content"] + if file.data and 'content' in file.data: + del file.data['content'] return files @@ -401,10 +384,8 @@ async def search_files( ############################ -@router.delete("/all") -async def delete_all_files( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.delete('/all') +async def delete_all_files(user=Depends(get_admin_user), db: Session = Depends(get_session)): result = Files.delete_all_files(db=db) if result: try: @@ -412,16 +393,16 @@ async def delete_all_files( VECTOR_DB_CLIENT.reset() except Exception as e: log.exception(e) - log.error("Error deleting files") + log.error('Error deleting files') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error deleting files"), + detail=ERROR_MESSAGES.DEFAULT('Error deleting files'), ) - return {"message": "All files deleted successfully"} + return {'message': 'All files deleted successfully'} else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error deleting files"), + detail=ERROR_MESSAGES.DEFAULT('Error deleting files'), ) @@ -430,10 +411,8 @@ async def delete_all_files( ############################ -@router.get("/{id}", response_model=Optional[FileModel]) -async def get_file_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}', response_model=Optional[FileModel]) +async def get_file_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): file = Files.get_file_by_id(id, db=db) if not file: @@ -442,11 +421,7 @@ async def get_file_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if ( - file.user_id == user.id - or user.role == "admin" - or has_access_to_file(id, "read", user, db=db) - ): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): return file else: raise HTTPException( @@ -455,7 +430,7 @@ async def get_file_by_id( ) -@router.get("/{id}/process/status") +@router.get('/{id}/process/status') async def get_file_process_status( id: str, stream: bool = Query(False), @@ -470,11 +445,7 @@ async def get_file_process_status( detail=ERROR_MESSAGES.NOT_FOUND, ) - if ( - file.user_id == user.id - or user.role == "admin" - or has_access_to_file(id, "read", user, db=db) - ): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): if stream: MAX_FILE_PROCESSING_DURATION = 3600 * 2 @@ -485,32 +456,32 @@ async def event_stream(file_id): for _ in range(MAX_FILE_PROCESSING_DURATION): file_item = Files.get_file_by_id(file_id) # Creates own session if file_item: - data = file_item.model_dump().get("data", {}) - status = data.get("status") + data = file_item.model_dump().get('data', {}) + status = data.get('status') if status: - event = {"status": status} - if status == "failed": - event["error"] = data.get("error") + event = {'status': status} + if status == 'failed': + event['error'] = data.get('error') - yield f"data: {json.dumps(event)}\n\n" - if status in ("completed", "failed"): + yield f'data: {json.dumps(event)}\n\n' + if status in ('completed', 'failed'): break else: # Legacy break else: - yield f"data: {json.dumps({'status': 'not_found'})}\n\n" + yield f'data: {json.dumps({"status": "not_found"})}\n\n' break await asyncio.sleep(1) return StreamingResponse( event_stream(file.id), - media_type="text/event-stream", + media_type='text/event-stream', ) else: - return {"status": file.data.get("status", "pending")} + return {'status': file.data.get('status', 'pending')} else: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -523,10 +494,8 @@ async def event_stream(file_id): ############################ -@router.get("/{id}/data/content") -async def get_file_data_content_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}/data/content') +async def get_file_data_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): file = Files.get_file_by_id(id, db=db) if not file: @@ -535,12 +504,8 @@ async def get_file_data_content_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if ( - file.user_id == user.id - or user.role == "admin" - or has_access_to_file(id, "read", user, db=db) - ): - return {"content": file.data.get("content", "")} + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): + return {'content': file.data.get('content', '')} else: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -557,7 +522,7 @@ class ContentForm(BaseModel): content: str -@router.post("/{id}/data/content/update") +@router.post('/{id}/data/content/update') def update_file_data_content_by_id( request: Request, id: str, @@ -573,11 +538,7 @@ def update_file_data_content_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if ( - file.user_id == user.id - or user.role == "admin" - or has_access_to_file(id, "write", user, db=db) - ): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'write', user, db=db): try: process_file( request, @@ -588,7 +549,7 @@ def update_file_data_content_by_id( file = Files.get_file_by_id(id=id, db=db) except Exception as e: log.exception(e) - log.error(f"Error processing file: {file.id}") + log.error(f'Error processing file: {file.id}') # Propagate content change to all knowledge collections referencing # this file. Without this the old embeddings remain in the knowledge @@ -597,9 +558,7 @@ def update_file_data_content_by_id( for knowledge in knowledges: try: # Remove old embeddings for this file from the KB collection - VECTOR_DB_CLIENT.delete( - collection_name=knowledge.id, filter={"file_id": id} - ) + VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) # Re-add from the now-updated file-{file_id} collection process_file( request, @@ -608,12 +567,9 @@ def update_file_data_content_by_id( db=db, ) except Exception as e: - log.warning( - f"Failed to update knowledge {knowledge.id} after " - f"content change for file {id}: {e}" - ) + log.warning(f'Failed to update knowledge {knowledge.id} after content change for file {id}: {e}') - return {"content": file.data.get("content", "")} + return {'content': file.data.get('content', '')} else: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -626,7 +582,7 @@ def update_file_data_content_by_id( ############################ -@router.get("/{id}/content") +@router.get('/{id}/content') async def get_file_content_by_id( id: str, user=Depends(get_verified_user), @@ -641,11 +597,7 @@ async def get_file_content_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if ( - file.user_id == user.id - or user.role == "admin" - or has_access_to_file(id, "read", user, db=db) - ): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): try: file_path = Storage.get_file(file.path) file_path = Path(file_path) @@ -653,30 +605,22 @@ async def get_file_content_by_id( # Check if the file already exists in the cache if file_path.is_file(): # Handle Unicode filenames - filename = file.meta.get("name", file.filename) + filename = file.meta.get('name', file.filename) encoded_filename = quote(filename) # RFC5987 encoding - content_type = file.meta.get("content_type") - filename = file.meta.get("name", file.filename) + content_type = file.meta.get('content_type') + filename = file.meta.get('name', file.filename) encoded_filename = quote(filename) headers = {} if attachment: - headers["Content-Disposition"] = ( - f"attachment; filename*=UTF-8''{encoded_filename}" - ) + headers['Content-Disposition'] = f"attachment; filename*=UTF-8''{encoded_filename}" else: - if content_type == "application/pdf" or filename.lower().endswith( - ".pdf" - ): - headers["Content-Disposition"] = ( - f"inline; filename*=UTF-8''{encoded_filename}" - ) - content_type = "application/pdf" - elif content_type != "text/plain": - headers["Content-Disposition"] = ( - f"attachment; filename*=UTF-8''{encoded_filename}" - ) + if content_type == 'application/pdf' or filename.lower().endswith('.pdf'): + headers['Content-Disposition'] = f"inline; filename*=UTF-8''{encoded_filename}" + content_type = 'application/pdf' + elif content_type != 'text/plain': + headers['Content-Disposition'] = f"attachment; filename*=UTF-8''{encoded_filename}" return FileResponse(file_path, headers=headers, media_type=content_type) @@ -689,10 +633,10 @@ async def get_file_content_by_id( raise e except Exception as e: log.exception(e) - log.error("Error getting file content") + log.error('Error getting file content') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error getting file content"), + detail=ERROR_MESSAGES.DEFAULT('Error getting file content'), ) else: raise HTTPException( @@ -701,10 +645,8 @@ async def get_file_content_by_id( ) -@router.get("/{id}/content/html") -async def get_html_file_content_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}/content/html') +async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): file = Files.get_file_by_id(id, db=db) if not file: @@ -714,24 +656,20 @@ async def get_html_file_content_by_id( ) file_user = Users.get_user_by_id(file.user_id, db=db) - if not file_user.role == "admin": + if not file_user.role == 'admin': raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - if ( - file.user_id == user.id - or user.role == "admin" - or has_access_to_file(id, "read", user, db=db) - ): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): try: file_path = Storage.get_file(file.path) file_path = Path(file_path) # Check if the file already exists in the cache if file_path.is_file(): - log.info(f"file_path: {file_path}") + log.info(f'file_path: {file_path}') return FileResponse(file_path) else: raise HTTPException( @@ -742,10 +680,10 @@ async def get_html_file_content_by_id( raise e except Exception as e: log.exception(e) - log.error("Error getting file content") + log.error('Error getting file content') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error getting file content"), + detail=ERROR_MESSAGES.DEFAULT('Error getting file content'), ) else: raise HTTPException( @@ -754,10 +692,8 @@ async def get_html_file_content_by_id( ) -@router.get("/{id}/content/{file_name}") -async def get_file_content_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}/content/{file_name}') +async def get_file_content_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): file = Files.get_file_by_id(id, db=db) if not file: @@ -766,19 +702,13 @@ async def get_file_content_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if ( - file.user_id == user.id - or user.role == "admin" - or has_access_to_file(id, "read", user, db=db) - ): + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'read', user, db=db): file_path = file.path # Handle Unicode filenames - filename = file.meta.get("name", file.filename) + filename = file.meta.get('name', file.filename) encoded_filename = quote(filename) # RFC5987 encoding - headers = { - "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" - } + headers = {'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}"} if file_path: file_path = Storage.get_file(file_path) @@ -794,16 +724,16 @@ async def get_file_content_by_id( ) else: # File path doesnโ€™t exist, return the content as .txt if possible - file_content = file.content.get("content", "") + file_content = file.data.get('content', '') file_name = file.filename # Create a generator that encodes the file content def generator(): - yield file_content.encode("utf-8") + yield file_content.encode('utf-8') return StreamingResponse( generator(), - media_type="text/plain", + media_type='text/plain', headers=headers, ) else: @@ -818,10 +748,8 @@ def generator(): ############################ -@router.delete("/{id}") -async def delete_file_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.delete('/{id}') +async def delete_file_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): file = Files.get_file_by_id(id, db=db) if not file: @@ -830,12 +758,7 @@ async def delete_file_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if ( - file.user_id == user.id - or user.role == "admin" - or has_access_to_file(id, "write", user, db=db) - ): - + if file.user_id == user.id or user.role == 'admin' or has_access_to_file(id, 'write', user, db=db): # Clean up KB associations and embeddings before deleting knowledges = Knowledges.get_knowledges_by_file_id(id, db=db) for knowledge in knowledges: @@ -843,33 +766,29 @@ async def delete_file_by_id( Knowledges.remove_file_from_knowledge_by_id(knowledge.id, id, db=db) # Clean KB embeddings (same logic as /knowledge/{id}/file/remove) try: - VECTOR_DB_CLIENT.delete( - collection_name=knowledge.id, filter={"file_id": id} - ) + VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': id}) if file.hash: - VECTOR_DB_CLIENT.delete( - collection_name=knowledge.id, filter={"hash": file.hash} - ) + VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'hash': file.hash}) except Exception as e: - log.debug(f"KB embedding cleanup for {knowledge.id}: {e}") + log.debug(f'KB embedding cleanup for {knowledge.id}: {e}') result = Files.delete_file_by_id(id, db=db) if result: try: Storage.delete_file(file.path) - VECTOR_DB_CLIENT.delete(collection_name=f"file-{id}") + VECTOR_DB_CLIENT.delete(collection_name=f'file-{id}') except Exception as e: log.exception(e) - log.error("Error deleting files") + log.error('Error deleting files') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error deleting files"), + detail=ERROR_MESSAGES.DEFAULT('Error deleting files'), ) - return {"message": "File deleted successfully"} + return {'message': 'File deleted successfully'} else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error deleting file"), + detail=ERROR_MESSAGES.DEFAULT('Error deleting file'), ) else: raise HTTPException( diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index f6269e7b72..0bf5a87f1e 100644 --- a/backend/open_webui/routers/folders.py +++ b/backend/open_webui/routers/folders.py @@ -44,7 +44,7 @@ ############################ -@router.get("/", response_model=list[FolderNameIdResponse]) +@router.get('/', response_model=list[FolderNameIdResponse]) async def get_folders( request: Request, user=Depends(get_verified_user), @@ -56,9 +56,9 @@ async def get_folders( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if user.role != "admin" and not has_permission( + if user.role != 'admin' and not has_permission( user.id, - "features.folders", + 'features.folders', request.app.state.config.USER_PERMISSIONS, db=db, ): @@ -72,35 +72,24 @@ async def get_folders( # Verify folder data integrity folder_list = [] for folder in folders: - if folder.parent_id and not Folders.get_folder_by_id_and_user_id( - folder.parent_id, user.id, db=db - ): - folder = Folders.update_folder_parent_id_by_id_and_user_id( - folder.id, user.id, None, db=db - ) + if folder.parent_id and not Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db): + folder = Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db) if folder.data: - if "files" in folder.data: + if 'files' in folder.data: valid_files = [] - for file in folder.data["files"]: - - if file.get("type") == "file": - if Files.check_access_by_user_id( - file.get("id"), user.id, "read", db=db - ): + for file in folder.data['files']: + if file.get('type') == 'file': + if Files.check_access_by_user_id(file.get('id'), user.id, 'read', db=db): valid_files.append(file) - elif file.get("type") == "collection": - if Knowledges.check_access_by_user_id( - file.get("id"), user.id, "read", db=db - ): + elif file.get('type') == 'collection': + if Knowledges.check_access_by_user_id(file.get('id'), user.id, 'read', db=db): valid_files.append(file) else: valid_files.append(file) - folder.data["files"] = valid_files - Folders.update_folder_by_id_and_user_id( - folder.id, user.id, FolderUpdateForm(data=folder.data), db=db - ) + folder.data['files'] = valid_files + Folders.update_folder_by_id_and_user_id(folder.id, user.id, FolderUpdateForm(data=folder.data), db=db) folder_list.append(FolderNameIdResponse(**folder.model_dump())) @@ -112,33 +101,29 @@ async def get_folders( ############################ -@router.post("/") +@router.post('/') def create_folder( form_data: FolderForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - folder = Folders.get_folder_by_parent_id_and_user_id_and_name( - form_data.parent_id, user.id, form_data.name, db=db - ) + folder = Folders.get_folder_by_parent_id_and_user_id_and_name(form_data.parent_id, user.id, form_data.name, db=db) if folder: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Folder already exists"), + detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) try: - folder = Folders.insert_new_folder( - user.id, form_data, form_data.parent_id, db=db - ) + folder = Folders.insert_new_folder(user.id, form_data, form_data.parent_id, db=db) return folder except Exception as e: log.exception(e) - log.error("Error creating folder") + log.error('Error creating folder') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error creating folder"), + detail=ERROR_MESSAGES.DEFAULT('Error creating folder'), ) @@ -147,10 +132,8 @@ def create_folder( ############################ -@router.get("/{id}", response_model=Optional[FolderModel]) -async def get_folder_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}', response_model=Optional[FolderModel]) +async def get_folder_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: return folder @@ -166,7 +149,7 @@ async def get_folder_by_id( ############################ -@router.post("/{id}/update") +@router.post('/{id}/update') async def update_folder_name_by_id( id: str, form_data: FolderUpdateForm, @@ -175,7 +158,6 @@ async def update_folder_name_by_id( ): folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: - if form_data.name is not None: # Check if folder with same name exists existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name( @@ -184,20 +166,18 @@ async def update_folder_name_by_id( if existing_folder and existing_folder.id != id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Folder already exists"), + detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) try: - folder = Folders.update_folder_by_id_and_user_id( - id, user.id, form_data, db=db - ) + folder = Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db) return folder except Exception as e: log.exception(e) - log.error(f"Error updating folder: {id}") + log.error(f'Error updating folder: {id}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating folder"), + detail=ERROR_MESSAGES.DEFAULT('Error updating folder'), ) else: raise HTTPException( @@ -215,7 +195,7 @@ class FolderParentIdForm(BaseModel): parent_id: Optional[str] = None -@router.post("/{id}/update/parent") +@router.post('/{id}/update/parent') async def update_folder_parent_id_by_id( id: str, form_data: FolderParentIdForm, @@ -231,20 +211,18 @@ async def update_folder_parent_id_by_id( if existing_folder: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Folder already exists"), + detail=ERROR_MESSAGES.DEFAULT('Folder already exists'), ) try: - folder = Folders.update_folder_parent_id_by_id_and_user_id( - id, user.id, form_data.parent_id, db=db - ) + folder = Folders.update_folder_parent_id_by_id_and_user_id(id, user.id, form_data.parent_id, db=db) return folder except Exception as e: log.exception(e) - log.error(f"Error updating folder: {id}") + log.error(f'Error updating folder: {id}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating folder"), + detail=ERROR_MESSAGES.DEFAULT('Error updating folder'), ) else: raise HTTPException( @@ -262,7 +240,7 @@ class FolderIsExpandedForm(BaseModel): is_expanded: bool -@router.post("/{id}/update/expanded") +@router.post('/{id}/update/expanded') async def update_folder_is_expanded_by_id( id: str, form_data: FolderIsExpandedForm, @@ -272,16 +250,14 @@ async def update_folder_is_expanded_by_id( folder = Folders.get_folder_by_id_and_user_id(id, user.id, db=db) if folder: try: - folder = Folders.update_folder_is_expanded_by_id_and_user_id( - id, user.id, form_data.is_expanded, db=db - ) + folder = Folders.update_folder_is_expanded_by_id_and_user_id(id, user.id, form_data.is_expanded, db=db) return folder except Exception as e: log.exception(e) - log.error(f"Error updating folder: {id}") + log.error(f'Error updating folder: {id}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating folder"), + detail=ERROR_MESSAGES.DEFAULT('Error updating folder'), ) else: raise HTTPException( @@ -295,7 +271,7 @@ async def update_folder_is_expanded_by_id( ############################ -@router.delete("/{id}") +@router.delete('/{id}') async def delete_folder_by_id( request: Request, id: str, @@ -305,9 +281,9 @@ async def delete_folder_by_id( ): if Chats.count_chats_by_folder_id_and_user_id(id, user.id, db=db): chat_delete_permission = has_permission( - user.id, "chat.delete", request.app.state.config.USER_PERMISSIONS, db=db + user.id, 'chat.delete', request.app.state.config.USER_PERMISSIONS, db=db ) - if user.role != "admin" and not chat_delete_permission: + if user.role != 'admin' and not chat_delete_permission: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -319,33 +295,25 @@ async def delete_folder_by_id( folder = folders.pop() if folder: try: - folder_ids = Folders.delete_folder_by_id_and_user_id( - folder.id, user.id, db=db - ) + folder_ids = Folders.delete_folder_by_id_and_user_id(folder.id, user.id, db=db) for folder_id in folder_ids: if delete_contents: - Chats.delete_chats_by_user_id_and_folder_id( - user.id, folder_id, db=db - ) + Chats.delete_chats_by_user_id_and_folder_id(user.id, folder_id, db=db) else: - Chats.move_chats_by_user_id_and_folder_id( - user.id, folder_id, None, db=db - ) + Chats.move_chats_by_user_id_and_folder_id(user.id, folder_id, None, db=db) return True except Exception as e: log.exception(e) - log.error(f"Error deleting folder: {id}") + log.error(f'Error deleting folder: {id}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error deleting folder"), + detail=ERROR_MESSAGES.DEFAULT('Error deleting folder'), ) finally: # Get all subfolders - subfolders = Folders.get_folders_by_parent_id_and_user_id( - folder.id, user.id, db=db - ) + subfolders = Folders.get_folders_by_parent_id_and_user_id(folder.id, user.id, db=db) folders.extend(subfolders) else: diff --git a/backend/open_webui/routers/functions.py b/backend/open_webui/routers/functions.py index 3af3b1664a..01bcbc411c 100644 --- a/backend/open_webui/routers/functions.py +++ b/backend/open_webui/routers/functions.py @@ -36,20 +36,18 @@ ############################ # GetFunctions +# Our daily functions give us, and forgive us +# our deprecated methods, as we refactor those who depend on us. ############################ -@router.get("/", response_model=list[FunctionResponse]) -async def get_functions( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/', response_model=list[FunctionResponse]) +async def get_functions(user=Depends(get_verified_user), db: Session = Depends(get_session)): return Functions.get_functions(db=db) -@router.get("/list", response_model=list[FunctionUserResponse]) -async def get_function_list( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/list', response_model=list[FunctionUserResponse]) +async def get_function_list(user=Depends(get_admin_user), db: Session = Depends(get_session)): return Functions.get_function_list(db=db) @@ -58,7 +56,7 @@ async def get_function_list( ############################ -@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel]) +@router.get('/export', response_model=list[FunctionModel | FunctionWithValvesModel]) async def get_functions( include_valves: bool = False, user=Depends(get_admin_user), @@ -78,70 +76,59 @@ class LoadUrlForm(BaseModel): def github_url_to_raw_url(url: str) -> str: # Handle 'tree' (folder) URLs (add main.py at the end) - m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url) + m1 = re.match(r'https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)', url) if m1: org, repo, branch, path = m1.groups() - return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py" + return f'https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip("/")}/main.py' # Handle 'blob' (file) URLs - m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url) + m2 = re.match(r'https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)', url) if m2: org, repo, branch, path = m2.groups() - return ( - f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}" - ) + return f'https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}' # No match; return as-is return url -@router.post("/load/url", response_model=Optional[dict]) -async def load_function_from_url( - request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user) -): +@router.post('/load/url', response_model=Optional[dict]) +async def load_function_from_url(request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)): # NOTE: This is NOT a SSRF vulnerability: # This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use, # and does NOT accept untrusted user input. Access is enforced by authentication. url = str(form_data.url) if not url: - raise HTTPException(status_code=400, detail="Please enter a valid URL") + raise HTTPException(status_code=400, detail='Please enter a valid URL') url = github_url_to_raw_url(url) - url_parts = url.rstrip("/").split("/") + url_parts = url.rstrip('/').split('/') file_name = url_parts[-1] function_name = ( file_name[:-3] - if ( - file_name.endswith(".py") - and (not file_name.startswith(("main.py", "index.py", "__init__.py"))) - ) - else url_parts[-2] if len(url_parts) > 1 else "function" + if (file_name.endswith('.py') and (not file_name.startswith(('main.py', 'index.py', '__init__.py')))) + else url_parts[-2] + if len(url_parts) > 1 + else 'function' ) try: async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: - async with session.get( - url, headers={"Content-Type": "application/json"} - ) as resp: + async with session.get(url, headers={'Content-Type': 'application/json'}) as resp: if resp.status != 200: - raise HTTPException( - status_code=resp.status, detail="Failed to fetch the function" - ) + raise HTTPException(status_code=resp.status, detail='Failed to fetch the function') data = await resp.text() if not data: - raise HTTPException( - status_code=400, detail="No data received from the URL" - ) + raise HTTPException(status_code=400, detail='No data received from the URL') return { - "name": function_name, - "content": data, + 'name': function_name, + 'content': data, } except Exception as e: - raise HTTPException(status_code=500, detail=f"Error importing function: {e}") + raise HTTPException(status_code=500, detail=f'Error importing function: {e}') ############################ @@ -153,7 +140,7 @@ class SyncFunctionsForm(BaseModel): functions: list[FunctionWithValvesModel] = [] -@router.post("/sync", response_model=list[FunctionWithValvesModel]) +@router.post('/sync', response_model=list[FunctionWithValvesModel]) async def sync_functions( request: Request, form_data: SyncFunctionsForm, @@ -168,21 +155,17 @@ async def sync_functions( content=function.content, ) - if hasattr(function_module, "Valves") and function.valves: + if hasattr(function_module, 'Valves') and function.valves: Valves = function_module.Valves try: - Valves( - **{k: v for k, v in function.valves.items() if v is not None} - ) + Valves(**{k: v for k, v in function.valves.items() if v is not None}) except Exception as e: - log.exception( - f"Error validating valves for function {function.id}: {e}" - ) + log.exception(f'Error validating valves for function {function.id}: {e}') raise e return Functions.sync_functions(user.id, form_data.functions, db=db) except Exception as e: - log.exception(f"Failed to load a function: {e}") + log.exception(f'Failed to load a function: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -194,7 +177,7 @@ async def sync_functions( ############################ -@router.post("/create", response_model=Optional[FunctionResponse]) +@router.post('/create', response_model=Optional[FunctionResponse]) async def create_new_function( request: Request, form_data: FunctionForm, @@ -204,7 +187,7 @@ async def create_new_function( if not form_data.id.isidentifier(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Only alphanumeric characters and underscores are allowed in the id", + detail='Only alphanumeric characters and underscores are allowed in the id', ) form_data.id = form_data.id.lower() @@ -222,27 +205,23 @@ async def create_new_function( FUNCTIONS = request.app.state.FUNCTIONS FUNCTIONS[form_data.id] = function_module - function = Functions.insert_new_function( - user.id, function_type, form_data, db=db - ) + function = Functions.insert_new_function(user.id, function_type, form_data, db=db) - function_cache_dir = CACHE_DIR / "functions" / form_data.id + function_cache_dir = CACHE_DIR / 'functions' / form_data.id function_cache_dir.mkdir(parents=True, exist_ok=True) - if function_type == "filter" and getattr(function_module, "toggle", None): - Functions.update_function_metadata_by_id( - form_data.id, {"toggle": True}, db=db - ) + if function_type == 'filter' and getattr(function_module, 'toggle', None): + Functions.update_function_metadata_by_id(form_data.id, {'toggle': True}, db=db) if function: return function else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error creating function"), + detail=ERROR_MESSAGES.DEFAULT('Error creating function'), ) except Exception as e: - log.exception(f"Failed to create a new function: {e}") + log.exception(f'Failed to create a new function: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -259,10 +238,8 @@ async def create_new_function( ############################ -@router.get("/id/{id}", response_model=Optional[FunctionModel]) -async def get_function_by_id( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}', response_model=Optional[FunctionModel]) +async def get_function_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): function = Functions.get_function_by_id(id, db=db) if function: @@ -279,22 +256,18 @@ async def get_function_by_id( ############################ -@router.post("/id/{id}/toggle", response_model=Optional[FunctionModel]) -async def toggle_function_by_id( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.post('/id/{id}/toggle', response_model=Optional[FunctionModel]) +async def toggle_function_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): function = Functions.get_function_by_id(id, db=db) if function: - function = Functions.update_function_by_id( - id, {"is_active": not function.is_active}, db=db - ) + function = Functions.update_function_by_id(id, {'is_active': not function.is_active}, db=db) if function: return function else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating function"), + detail=ERROR_MESSAGES.DEFAULT('Error updating function'), ) else: raise HTTPException( @@ -308,22 +281,18 @@ async def toggle_function_by_id( ############################ -@router.post("/id/{id}/toggle/global", response_model=Optional[FunctionModel]) -async def toggle_global_by_id( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.post('/id/{id}/toggle/global', response_model=Optional[FunctionModel]) +async def toggle_global_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): function = Functions.get_function_by_id(id, db=db) if function: - function = Functions.update_function_by_id( - id, {"is_global": not function.is_global}, db=db - ) + function = Functions.update_function_by_id(id, {'is_global': not function.is_global}, db=db) if function: return function else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating function"), + detail=ERROR_MESSAGES.DEFAULT('Error updating function'), ) else: raise HTTPException( @@ -337,7 +306,7 @@ async def toggle_global_by_id( ############################ -@router.post("/id/{id}/update", response_model=Optional[FunctionModel]) +@router.post('/id/{id}/update', response_model=Optional[FunctionModel]) async def update_function_by_id( request: Request, id: str, @@ -347,28 +316,26 @@ async def update_function_by_id( ): try: form_data.content = replace_imports(form_data.content) - function_module, function_type, frontmatter = load_function_module_by_id( - id, content=form_data.content - ) + function_module, function_type, frontmatter = load_function_module_by_id(id, content=form_data.content) form_data.meta.manifest = frontmatter FUNCTIONS = request.app.state.FUNCTIONS FUNCTIONS[id] = function_module - updated = {**form_data.model_dump(exclude={"id"}), "type": function_type} + updated = {**form_data.model_dump(exclude={'id'}), 'type': function_type} log.debug(updated) function = Functions.update_function_by_id(id, updated, db=db) - if function_type == "filter" and getattr(function_module, "toggle", None): - Functions.update_function_metadata_by_id(id, {"toggle": True}, db=db) + if function_type == 'filter' and getattr(function_module, 'toggle', None): + Functions.update_function_metadata_by_id(id, {'toggle': True}, db=db) if function: return function else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating function"), + detail=ERROR_MESSAGES.DEFAULT('Error updating function'), ) except Exception as e: @@ -383,7 +350,7 @@ async def update_function_by_id( ############################ -@router.delete("/id/{id}/delete", response_model=bool) +@router.delete('/id/{id}/delete', response_model=bool) async def delete_function_by_id( request: Request, id: str, @@ -405,10 +372,8 @@ async def delete_function_by_id( ############################ -@router.get("/id/{id}/valves", response_model=Optional[dict]) -async def get_function_valves_by_id( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}/valves', response_model=Optional[dict]) +async def get_function_valves_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): function = Functions.get_function_by_id(id, db=db) if function: try: @@ -431,7 +396,7 @@ async def get_function_valves_by_id( ############################ -@router.get("/id/{id}/valves/spec", response_model=Optional[dict]) +@router.get('/id/{id}/valves/spec', response_model=Optional[dict]) async def get_function_valves_spec_by_id( request: Request, id: str, @@ -440,11 +405,9 @@ async def get_function_valves_spec_by_id( ): function = Functions.get_function_by_id(id, db=db) if function: - function_module, function_type, frontmatter = get_function_module_from_cache( - request, id - ) + function_module, function_type, frontmatter = get_function_module_from_cache(request, id) - if hasattr(function_module, "Valves"): + if hasattr(function_module, 'Valves'): Valves = function_module.Valves schema = Valves.schema() # Resolve dynamic options for select dropdowns @@ -463,7 +426,7 @@ async def get_function_valves_spec_by_id( ############################ -@router.post("/id/{id}/valves/update", response_model=Optional[dict]) +@router.post('/id/{id}/valves/update', response_model=Optional[dict]) async def update_function_valves_by_id( request: Request, id: str, @@ -473,11 +436,9 @@ async def update_function_valves_by_id( ): function = Functions.get_function_by_id(id, db=db) if function: - function_module, function_type, frontmatter = get_function_module_from_cache( - request, id - ) + function_module, function_type, frontmatter = get_function_module_from_cache(request, id) - if hasattr(function_module, "Valves"): + if hasattr(function_module, 'Valves'): Valves = function_module.Valves try: @@ -488,7 +449,7 @@ async def update_function_valves_by_id( Functions.update_function_valves_by_id(id, valves_dict, db=db) return valves_dict except Exception as e: - log.exception(f"Error updating function values by id {id}: {e}") + log.exception(f'Error updating function values by id {id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -511,16 +472,12 @@ async def update_function_valves_by_id( ############################ -@router.get("/id/{id}/valves/user", response_model=Optional[dict]) -async def get_function_user_valves_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}/valves/user', response_model=Optional[dict]) +async def get_function_user_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): function = Functions.get_function_by_id(id, db=db) if function: try: - user_valves = Functions.get_user_valves_by_id_and_user_id( - id, user.id, db=db - ) + user_valves = Functions.get_user_valves_by_id_and_user_id(id, user.id, db=db) return user_valves except Exception as e: raise HTTPException( @@ -534,7 +491,7 @@ async def get_function_user_valves_by_id( ) -@router.get("/id/{id}/valves/user/spec", response_model=Optional[dict]) +@router.get('/id/{id}/valves/user/spec', response_model=Optional[dict]) async def get_function_user_valves_spec_by_id( request: Request, id: str, @@ -543,11 +500,9 @@ async def get_function_user_valves_spec_by_id( ): function = Functions.get_function_by_id(id, db=db) if function: - function_module, function_type, frontmatter = get_function_module_from_cache( - request, id - ) + function_module, function_type, frontmatter = get_function_module_from_cache(request, id) - if hasattr(function_module, "UserValves"): + if hasattr(function_module, 'UserValves'): UserValves = function_module.UserValves schema = UserValves.schema() # Resolve dynamic options for select dropdowns @@ -561,7 +516,7 @@ async def get_function_user_valves_spec_by_id( ) -@router.post("/id/{id}/valves/user/update", response_model=Optional[dict]) +@router.post('/id/{id}/valves/user/update', response_model=Optional[dict]) async def update_function_user_valves_by_id( request: Request, id: str, @@ -572,23 +527,19 @@ async def update_function_user_valves_by_id( function = Functions.get_function_by_id(id, db=db) if function: - function_module, function_type, frontmatter = get_function_module_from_cache( - request, id - ) + function_module, function_type, frontmatter = get_function_module_from_cache(request, id) - if hasattr(function_module, "UserValves"): + if hasattr(function_module, 'UserValves'): UserValves = function_module.UserValves try: form_data = {k: v for k, v in form_data.items() if v is not None} user_valves = UserValves(**form_data) user_valves_dict = user_valves.model_dump(exclude_unset=True) - Functions.update_user_valves_by_id_and_user_id( - id, user.id, user_valves_dict, db=db - ) + Functions.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db) return user_valves_dict except Exception as e: - log.exception(f"Error updating function user valves by id {id}: {e}") + log.exception(f'Error updating function user valves by id {id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index 3711a52ab4..4e9688c3d8 100755 --- a/backend/open_webui/routers/groups.py +++ b/backend/open_webui/routers/groups.py @@ -31,20 +31,19 @@ ############################ -@router.get("/", response_model=list[GroupResponse]) +@router.get('/', response_model=list[GroupResponse]) async def get_groups( share: Optional[bool] = None, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - filter = {} # Admins can share to all groups regardless of share setting - if user.role != "admin": - filter["member_id"] = user.id + if user.role != 'admin': + filter['member_id'] = user.id if share is not None: - filter["share"] = share + filter['share'] = share groups = Groups.get_groups(filter=filter, db=db) @@ -56,7 +55,7 @@ async def get_groups( ############################ -@router.post("/create", response_model=Optional[GroupResponse]) +@router.post('/create', response_model=Optional[GroupResponse]) async def create_new_group( form_data: GroupForm, user=Depends(get_admin_user), @@ -72,10 +71,10 @@ async def create_new_group( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error creating group"), + detail=ERROR_MESSAGES.DEFAULT('Error creating group'), ) except Exception as e: - log.exception(f"Error creating a new group: {e}") + log.exception(f'Error creating a new group: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -87,10 +86,8 @@ async def create_new_group( ############################ -@router.get("/id/{id}", response_model=Optional[GroupResponse]) -async def get_group_by_id( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}', response_model=Optional[GroupResponse]) +async def get_group_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): group = Groups.get_group_by_id(id, db=db) if group: return GroupResponse( @@ -104,10 +101,8 @@ async def get_group_by_id( ) -@router.get("/id/{id}/info", response_model=Optional[GroupInfoResponse]) -async def get_group_info_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}/info', response_model=Optional[GroupInfoResponse]) +async def get_group_info_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): group = Groups.get_group_by_id(id, db=db) if group: return GroupInfoResponse( @@ -131,10 +126,8 @@ class GroupExportResponse(GroupResponse): pass -@router.get("/id/{id}/export", response_model=Optional[GroupExportResponse]) -async def export_group_by_id( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}/export', response_model=Optional[GroupExportResponse]) +async def export_group_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): group = Groups.get_group_by_id(id, db=db) if group: return GroupExportResponse( @@ -154,15 +147,13 @@ async def export_group_by_id( ############################ -@router.post("/id/{id}/users", response_model=list[UserInfoResponse]) -async def get_users_in_group( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.post('/id/{id}/users', response_model=list[UserInfoResponse]) +async def get_users_in_group(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): try: users = Users.get_users_by_group_id(id, db=db) return users except Exception as e: - log.exception(f"Error adding users to group {id}: {e}") + log.exception(f'Error adding users to group {id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -174,7 +165,7 @@ async def get_users_in_group( ############################ -@router.post("/id/{id}/update", response_model=Optional[GroupResponse]) +@router.post('/id/{id}/update', response_model=Optional[GroupResponse]) async def update_group_by_id( id: str, form_data: GroupUpdateForm, @@ -191,10 +182,10 @@ async def update_group_by_id( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating group"), + detail=ERROR_MESSAGES.DEFAULT('Error updating group'), ) except Exception as e: - log.exception(f"Error updating group {id}: {e}") + log.exception(f'Error updating group {id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -206,7 +197,7 @@ async def update_group_by_id( ############################ -@router.post("/id/{id}/users/add", response_model=Optional[GroupResponse]) +@router.post('/id/{id}/users/add', response_model=Optional[GroupResponse]) async def add_user_to_group( id: str, form_data: UserIdsForm, @@ -226,17 +217,17 @@ async def add_user_to_group( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error adding users to group"), + detail=ERROR_MESSAGES.DEFAULT('Error adding users to group'), ) except Exception as e: - log.exception(f"Error adding users to group {id}: {e}") + log.exception(f'Error adding users to group {id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), ) -@router.post("/id/{id}/users/remove", response_model=Optional[GroupResponse]) +@router.post('/id/{id}/users/remove', response_model=Optional[GroupResponse]) async def remove_users_from_group( id: str, form_data: UserIdsForm, @@ -253,10 +244,10 @@ async def remove_users_from_group( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error removing users from group"), + detail=ERROR_MESSAGES.DEFAULT('Error removing users from group'), ) except Exception as e: - log.exception(f"Error removing users from group {id}: {e}") + log.exception(f'Error removing users from group {id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), @@ -268,10 +259,8 @@ async def remove_users_from_group( ############################ -@router.delete("/id/{id}/delete", response_model=bool) -async def delete_group_by_id( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.delete('/id/{id}/delete', response_model=bool) +async def delete_group_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): try: result = Groups.delete_group_by_id(id, db=db) if result: @@ -279,10 +268,10 @@ async def delete_group_by_id( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error deleting group"), + detail=ERROR_MESSAGES.DEFAULT('Error deleting group'), ) except Exception as e: - log.exception(f"Error deleting group {id}: {e}") + log.exception(f'Error deleting group {id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(e), diff --git a/backend/open_webui/routers/images.py b/backend/open_webui/routers/images.py index 48209fc05c..dca9a58a7a 100644 --- a/backend/open_webui/routers/images.py +++ b/backend/open_webui/routers/images.py @@ -42,67 +42,67 @@ log = logging.getLogger(__name__) -IMAGE_CACHE_DIR = CACHE_DIR / "image" / "generations" +# An image can lie as easily as it can illuminate. Let what +# is generated here be honest about what it shows. +IMAGE_CACHE_DIR = CACHE_DIR / 'image' / 'generations' IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) router = APIRouter() def set_image_model(request: Request, model: str): - log.info(f"Setting image model to {model}") + log.info(f'Setting image model to {model}') request.app.state.config.IMAGE_GENERATION_MODEL = model - if request.app.state.config.IMAGE_GENERATION_ENGINE in ["", "automatic1111"]: + if request.app.state.config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']: api_auth = get_automatic1111_api_auth(request) try: r = requests.get( - url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options", - headers={"authorization": api_auth}, + url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', + headers={'authorization': api_auth}, ) options = r.json() - if model != options["sd_model_checkpoint"]: - options["sd_model_checkpoint"] = model + if model != options['sd_model_checkpoint']: + options['sd_model_checkpoint'] = model r = requests.post( - url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options", + url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', json=options, - headers={"authorization": api_auth}, + headers={'authorization': api_auth}, ) except Exception as e: - log.debug(f"{e}") + log.debug(f'{e}') return request.app.state.config.IMAGE_GENERATION_MODEL def get_image_model(request): - if request.app.state.config.IMAGE_GENERATION_ENGINE == "openai": + if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': return ( request.app.state.config.IMAGE_GENERATION_MODEL if request.app.state.config.IMAGE_GENERATION_MODEL - else "dall-e-2" + else 'dall-e-2' ) - elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": + elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini': return ( request.app.state.config.IMAGE_GENERATION_MODEL if request.app.state.config.IMAGE_GENERATION_MODEL - else "imagen-3.0-generate-002" + else 'imagen-3.0-generate-002' ) - elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": + elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': return ( - request.app.state.config.IMAGE_GENERATION_MODEL - if request.app.state.config.IMAGE_GENERATION_MODEL - else "" + request.app.state.config.IMAGE_GENERATION_MODEL if request.app.state.config.IMAGE_GENERATION_MODEL else '' ) elif ( - request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111" - or request.app.state.config.IMAGE_GENERATION_ENGINE == "" + request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111' + or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): try: r = requests.get( - url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options", - headers={"authorization": get_automatic1111_api_auth(request)}, + url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', + headers={'authorization': get_automatic1111_api_auth(request)}, ) options = r.json() - return options["sd_model_checkpoint"] + return options['sd_model_checkpoint'] except Exception as e: request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) @@ -151,79 +151,71 @@ class ImagesConfig(BaseModel): IMAGES_EDIT_COMFYUI_WORKFLOW_NODES: list[dict] -@router.get("/config", response_model=ImagesConfig) +@router.get('/config', response_model=ImagesConfig) async def get_config(request: Request, user=Depends(get_admin_user)): return { - "ENABLE_IMAGE_GENERATION": request.app.state.config.ENABLE_IMAGE_GENERATION, - "ENABLE_IMAGE_PROMPT_GENERATION": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, - "IMAGE_GENERATION_ENGINE": request.app.state.config.IMAGE_GENERATION_ENGINE, - "IMAGE_GENERATION_MODEL": request.app.state.config.IMAGE_GENERATION_MODEL, - "IMAGE_SIZE": request.app.state.config.IMAGE_SIZE, - "IMAGE_STEPS": request.app.state.config.IMAGE_STEPS, - "IMAGES_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, - "IMAGES_OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, - "IMAGES_OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION, - "IMAGES_OPENAI_API_PARAMS": request.app.state.config.IMAGES_OPENAI_API_PARAMS, - "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL, - "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH, - "AUTOMATIC1111_PARAMS": request.app.state.config.AUTOMATIC1111_PARAMS, - "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL, - "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY, - "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, - "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, - "IMAGES_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL, - "IMAGES_GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY, - "IMAGES_GEMINI_ENDPOINT_METHOD": request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD, - "ENABLE_IMAGE_EDIT": request.app.state.config.ENABLE_IMAGE_EDIT, - "IMAGE_EDIT_ENGINE": request.app.state.config.IMAGE_EDIT_ENGINE, - "IMAGE_EDIT_MODEL": request.app.state.config.IMAGE_EDIT_MODEL, - "IMAGE_EDIT_SIZE": request.app.state.config.IMAGE_EDIT_SIZE, - "IMAGES_EDIT_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL, - "IMAGES_EDIT_OPENAI_API_KEY": request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY, - "IMAGES_EDIT_OPENAI_API_VERSION": request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION, - "IMAGES_EDIT_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL, - "IMAGES_EDIT_GEMINI_API_KEY": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, - "IMAGES_EDIT_COMFYUI_BASE_URL": request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, - "IMAGES_EDIT_COMFYUI_API_KEY": request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, - "IMAGES_EDIT_COMFYUI_WORKFLOW": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, - "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, + 'ENABLE_IMAGE_GENERATION': request.app.state.config.ENABLE_IMAGE_GENERATION, + 'ENABLE_IMAGE_PROMPT_GENERATION': request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, + 'IMAGE_GENERATION_ENGINE': request.app.state.config.IMAGE_GENERATION_ENGINE, + 'IMAGE_GENERATION_MODEL': request.app.state.config.IMAGE_GENERATION_MODEL, + 'IMAGE_SIZE': request.app.state.config.IMAGE_SIZE, + 'IMAGE_STEPS': request.app.state.config.IMAGE_STEPS, + 'IMAGES_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_OPENAI_API_BASE_URL, + 'IMAGES_OPENAI_API_KEY': request.app.state.config.IMAGES_OPENAI_API_KEY, + 'IMAGES_OPENAI_API_VERSION': request.app.state.config.IMAGES_OPENAI_API_VERSION, + 'IMAGES_OPENAI_API_PARAMS': request.app.state.config.IMAGES_OPENAI_API_PARAMS, + 'AUTOMATIC1111_BASE_URL': request.app.state.config.AUTOMATIC1111_BASE_URL, + 'AUTOMATIC1111_API_AUTH': request.app.state.config.AUTOMATIC1111_API_AUTH, + 'AUTOMATIC1111_PARAMS': request.app.state.config.AUTOMATIC1111_PARAMS, + 'COMFYUI_BASE_URL': request.app.state.config.COMFYUI_BASE_URL, + 'COMFYUI_API_KEY': request.app.state.config.COMFYUI_API_KEY, + 'COMFYUI_WORKFLOW': request.app.state.config.COMFYUI_WORKFLOW, + 'COMFYUI_WORKFLOW_NODES': request.app.state.config.COMFYUI_WORKFLOW_NODES, + 'IMAGES_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_GEMINI_API_BASE_URL, + 'IMAGES_GEMINI_API_KEY': request.app.state.config.IMAGES_GEMINI_API_KEY, + 'IMAGES_GEMINI_ENDPOINT_METHOD': request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD, + 'ENABLE_IMAGE_EDIT': request.app.state.config.ENABLE_IMAGE_EDIT, + 'IMAGE_EDIT_ENGINE': request.app.state.config.IMAGE_EDIT_ENGINE, + 'IMAGE_EDIT_MODEL': request.app.state.config.IMAGE_EDIT_MODEL, + 'IMAGE_EDIT_SIZE': request.app.state.config.IMAGE_EDIT_SIZE, + 'IMAGES_EDIT_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL, + 'IMAGES_EDIT_OPENAI_API_KEY': request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY, + 'IMAGES_EDIT_OPENAI_API_VERSION': request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION, + 'IMAGES_EDIT_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL, + 'IMAGES_EDIT_GEMINI_API_KEY': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, + 'IMAGES_EDIT_COMFYUI_BASE_URL': request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, + 'IMAGES_EDIT_COMFYUI_API_KEY': request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, + 'IMAGES_EDIT_COMFYUI_WORKFLOW': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, + 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, } -@router.post("/config/update") -async def update_config( - request: Request, form_data: ImagesConfig, user=Depends(get_admin_user) -): +@router.post('/config/update') +async def update_config(request: Request, form_data: ImagesConfig, user=Depends(get_admin_user)): request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.ENABLE_IMAGE_GENERATION # Create Image - request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ( - form_data.ENABLE_IMAGE_PROMPT_GENERATION - ) + request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = form_data.ENABLE_IMAGE_PROMPT_GENERATION request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.IMAGE_GENERATION_ENGINE set_image_model(request, form_data.IMAGE_GENERATION_MODEL) - if form_data.IMAGE_SIZE == "auto" and not re.match( + if form_data.IMAGE_SIZE == 'auto' and not re.match( IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL ): raise HTTPException( status_code=400, detail=ERROR_MESSAGES.INCORRECT_FORMAT( - f" (auto is only allowed with models matching {IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN})." + f' (auto is only allowed with models matching {IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN}).' ), ) - pattern = r"^\d+x\d+$" - if ( - form_data.IMAGE_SIZE == "auto" - or form_data.IMAGE_SIZE == "" - or re.match(pattern, form_data.IMAGE_SIZE) - ): + pattern = r'^\d+x\d+$' + if form_data.IMAGE_SIZE == 'auto' or form_data.IMAGE_SIZE == '' or re.match(pattern, form_data.IMAGE_SIZE): request.app.state.config.IMAGE_SIZE = form_data.IMAGE_SIZE else: raise HTTPException( status_code=400, - detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (e.g., 512x512)."), + detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 512x512).'), ) if form_data.IMAGE_STEPS >= 0: @@ -231,36 +223,26 @@ async def update_config( else: raise HTTPException( status_code=400, - detail=ERROR_MESSAGES.INCORRECT_FORMAT(" (e.g., 50)."), + detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 50).'), ) - request.app.state.config.IMAGES_OPENAI_API_BASE_URL = ( - form_data.IMAGES_OPENAI_API_BASE_URL - ) + request.app.state.config.IMAGES_OPENAI_API_BASE_URL = form_data.IMAGES_OPENAI_API_BASE_URL request.app.state.config.IMAGES_OPENAI_API_KEY = form_data.IMAGES_OPENAI_API_KEY - request.app.state.config.IMAGES_OPENAI_API_VERSION = ( - form_data.IMAGES_OPENAI_API_VERSION - ) - request.app.state.config.IMAGES_OPENAI_API_PARAMS = ( - form_data.IMAGES_OPENAI_API_PARAMS - ) + request.app.state.config.IMAGES_OPENAI_API_VERSION = form_data.IMAGES_OPENAI_API_VERSION + request.app.state.config.IMAGES_OPENAI_API_PARAMS = form_data.IMAGES_OPENAI_API_PARAMS request.app.state.config.AUTOMATIC1111_BASE_URL = form_data.AUTOMATIC1111_BASE_URL request.app.state.config.AUTOMATIC1111_API_AUTH = form_data.AUTOMATIC1111_API_AUTH request.app.state.config.AUTOMATIC1111_PARAMS = form_data.AUTOMATIC1111_PARAMS - request.app.state.config.COMFYUI_BASE_URL = form_data.COMFYUI_BASE_URL.strip("/") + request.app.state.config.COMFYUI_BASE_URL = form_data.COMFYUI_BASE_URL.strip('/') request.app.state.config.COMFYUI_API_KEY = form_data.COMFYUI_API_KEY request.app.state.config.COMFYUI_WORKFLOW = form_data.COMFYUI_WORKFLOW request.app.state.config.COMFYUI_WORKFLOW_NODES = form_data.COMFYUI_WORKFLOW_NODES - request.app.state.config.IMAGES_GEMINI_API_BASE_URL = ( - form_data.IMAGES_GEMINI_API_BASE_URL - ) + request.app.state.config.IMAGES_GEMINI_API_BASE_URL = form_data.IMAGES_GEMINI_API_BASE_URL request.app.state.config.IMAGES_GEMINI_API_KEY = form_data.IMAGES_GEMINI_API_KEY - request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = ( - form_data.IMAGES_GEMINI_ENDPOINT_METHOD - ) + request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD = form_data.IMAGES_GEMINI_ENDPOINT_METHOD # Edit Image request.app.state.config.ENABLE_IMAGE_EDIT = form_data.ENABLE_IMAGE_EDIT @@ -268,107 +250,85 @@ async def update_config( request.app.state.config.IMAGE_EDIT_MODEL = form_data.IMAGE_EDIT_MODEL request.app.state.config.IMAGE_EDIT_SIZE = form_data.IMAGE_EDIT_SIZE - request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = ( - form_data.IMAGES_EDIT_OPENAI_API_BASE_URL - ) - request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY = ( - form_data.IMAGES_EDIT_OPENAI_API_KEY - ) - request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = ( - form_data.IMAGES_EDIT_OPENAI_API_VERSION - ) + request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL = form_data.IMAGES_EDIT_OPENAI_API_BASE_URL + request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY = form_data.IMAGES_EDIT_OPENAI_API_KEY + request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION = form_data.IMAGES_EDIT_OPENAI_API_VERSION - request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = ( - form_data.IMAGES_EDIT_GEMINI_API_BASE_URL - ) - request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY = ( - form_data.IMAGES_EDIT_GEMINI_API_KEY - ) + request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL = form_data.IMAGES_EDIT_GEMINI_API_BASE_URL + request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY = form_data.IMAGES_EDIT_GEMINI_API_KEY - request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = ( - form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip("/") - ) - request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = ( - form_data.IMAGES_EDIT_COMFYUI_API_KEY - ) - request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = ( - form_data.IMAGES_EDIT_COMFYUI_WORKFLOW - ) - request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = ( - form_data.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES - ) + request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL = form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip('/') + request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY = form_data.IMAGES_EDIT_COMFYUI_API_KEY + request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW = form_data.IMAGES_EDIT_COMFYUI_WORKFLOW + request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = form_data.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES return { - "ENABLE_IMAGE_GENERATION": request.app.state.config.ENABLE_IMAGE_GENERATION, - "ENABLE_IMAGE_PROMPT_GENERATION": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, - "IMAGE_GENERATION_ENGINE": request.app.state.config.IMAGE_GENERATION_ENGINE, - "IMAGE_GENERATION_MODEL": request.app.state.config.IMAGE_GENERATION_MODEL, - "IMAGE_SIZE": request.app.state.config.IMAGE_SIZE, - "IMAGE_STEPS": request.app.state.config.IMAGE_STEPS, - "IMAGES_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL, - "IMAGES_OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY, - "IMAGES_OPENAI_API_VERSION": request.app.state.config.IMAGES_OPENAI_API_VERSION, - "IMAGES_OPENAI_API_PARAMS": request.app.state.config.IMAGES_OPENAI_API_PARAMS, - "AUTOMATIC1111_BASE_URL": request.app.state.config.AUTOMATIC1111_BASE_URL, - "AUTOMATIC1111_API_AUTH": request.app.state.config.AUTOMATIC1111_API_AUTH, - "AUTOMATIC1111_PARAMS": request.app.state.config.AUTOMATIC1111_PARAMS, - "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL, - "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY, - "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW, - "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES, - "IMAGES_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_GEMINI_API_BASE_URL, - "IMAGES_GEMINI_API_KEY": request.app.state.config.IMAGES_GEMINI_API_KEY, - "IMAGES_GEMINI_ENDPOINT_METHOD": request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD, - "ENABLE_IMAGE_EDIT": request.app.state.config.ENABLE_IMAGE_EDIT, - "IMAGE_EDIT_ENGINE": request.app.state.config.IMAGE_EDIT_ENGINE, - "IMAGE_EDIT_MODEL": request.app.state.config.IMAGE_EDIT_MODEL, - "IMAGE_EDIT_SIZE": request.app.state.config.IMAGE_EDIT_SIZE, - "IMAGES_EDIT_OPENAI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL, - "IMAGES_EDIT_OPENAI_API_KEY": request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY, - "IMAGES_EDIT_OPENAI_API_VERSION": request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION, - "IMAGES_EDIT_GEMINI_API_BASE_URL": request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL, - "IMAGES_EDIT_GEMINI_API_KEY": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, - "IMAGES_EDIT_COMFYUI_BASE_URL": request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, - "IMAGES_EDIT_COMFYUI_API_KEY": request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, - "IMAGES_EDIT_COMFYUI_WORKFLOW": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, - "IMAGES_EDIT_COMFYUI_WORKFLOW_NODES": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, + 'ENABLE_IMAGE_GENERATION': request.app.state.config.ENABLE_IMAGE_GENERATION, + 'ENABLE_IMAGE_PROMPT_GENERATION': request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION, + 'IMAGE_GENERATION_ENGINE': request.app.state.config.IMAGE_GENERATION_ENGINE, + 'IMAGE_GENERATION_MODEL': request.app.state.config.IMAGE_GENERATION_MODEL, + 'IMAGE_SIZE': request.app.state.config.IMAGE_SIZE, + 'IMAGE_STEPS': request.app.state.config.IMAGE_STEPS, + 'IMAGES_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_OPENAI_API_BASE_URL, + 'IMAGES_OPENAI_API_KEY': request.app.state.config.IMAGES_OPENAI_API_KEY, + 'IMAGES_OPENAI_API_VERSION': request.app.state.config.IMAGES_OPENAI_API_VERSION, + 'IMAGES_OPENAI_API_PARAMS': request.app.state.config.IMAGES_OPENAI_API_PARAMS, + 'AUTOMATIC1111_BASE_URL': request.app.state.config.AUTOMATIC1111_BASE_URL, + 'AUTOMATIC1111_API_AUTH': request.app.state.config.AUTOMATIC1111_API_AUTH, + 'AUTOMATIC1111_PARAMS': request.app.state.config.AUTOMATIC1111_PARAMS, + 'COMFYUI_BASE_URL': request.app.state.config.COMFYUI_BASE_URL, + 'COMFYUI_API_KEY': request.app.state.config.COMFYUI_API_KEY, + 'COMFYUI_WORKFLOW': request.app.state.config.COMFYUI_WORKFLOW, + 'COMFYUI_WORKFLOW_NODES': request.app.state.config.COMFYUI_WORKFLOW_NODES, + 'IMAGES_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_GEMINI_API_BASE_URL, + 'IMAGES_GEMINI_API_KEY': request.app.state.config.IMAGES_GEMINI_API_KEY, + 'IMAGES_GEMINI_ENDPOINT_METHOD': request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD, + 'ENABLE_IMAGE_EDIT': request.app.state.config.ENABLE_IMAGE_EDIT, + 'IMAGE_EDIT_ENGINE': request.app.state.config.IMAGE_EDIT_ENGINE, + 'IMAGE_EDIT_MODEL': request.app.state.config.IMAGE_EDIT_MODEL, + 'IMAGE_EDIT_SIZE': request.app.state.config.IMAGE_EDIT_SIZE, + 'IMAGES_EDIT_OPENAI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL, + 'IMAGES_EDIT_OPENAI_API_KEY': request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY, + 'IMAGES_EDIT_OPENAI_API_VERSION': request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION, + 'IMAGES_EDIT_GEMINI_API_BASE_URL': request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL, + 'IMAGES_EDIT_GEMINI_API_KEY': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, + 'IMAGES_EDIT_COMFYUI_BASE_URL': request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, + 'IMAGES_EDIT_COMFYUI_API_KEY': request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, + 'IMAGES_EDIT_COMFYUI_WORKFLOW': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, + 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, } def get_automatic1111_api_auth(request: Request): if request.app.state.config.AUTOMATIC1111_API_AUTH is None: - return "" + return '' else: - auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode( - "utf-8" - ) + auth1111_byte_string = request.app.state.config.AUTOMATIC1111_API_AUTH.encode('utf-8') auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string) - auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode("utf-8") - return f"Basic {auth1111_base64_encoded_string}" + auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode('utf-8') + return f'Basic {auth1111_base64_encoded_string}' -@router.get("/config/url/verify") +@router.get('/config/url/verify') async def verify_url(request: Request, user=Depends(get_admin_user)): - if request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111": + if request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111': try: r = requests.get( - url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options", - headers={"authorization": get_automatic1111_api_auth(request)}, + url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options', + headers={'authorization': get_automatic1111_api_auth(request)}, ) r.raise_for_status() return True except Exception: request.app.state.config.ENABLE_IMAGE_GENERATION = False raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL) - elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": + elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': headers = None if request.app.state.config.COMFYUI_API_KEY: - headers = { - "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}" - } + headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} try: r = requests.get( - url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info", + url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info', headers=headers, ) r.raise_for_status() @@ -380,27 +340,25 @@ async def verify_url(request: Request, user=Depends(get_admin_user)): return True -@router.get("/models") +@router.get('/models') def get_models(request: Request, user=Depends(get_verified_user)): try: - if request.app.state.config.IMAGE_GENERATION_ENGINE == "openai": + if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': return [ - {"id": "dall-e-2", "name": "DALLยทE 2"}, - {"id": "dall-e-3", "name": "DALLยทE 3"}, - {"id": "gpt-image-1", "name": "GPT-IMAGE 1"}, - {"id": "gpt-image-1.5", "name": "GPT-IMAGE 1.5"}, + {'id': 'dall-e-2', 'name': 'DALLยทE 2'}, + {'id': 'dall-e-3', 'name': 'DALLยทE 3'}, + {'id': 'gpt-image-1', 'name': 'GPT-IMAGE 1'}, + {'id': 'gpt-image-1.5', 'name': 'GPT-IMAGE 1.5'}, ] - elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": + elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini': return [ - {"id": "imagen-3.0-generate-002", "name": "imagen-3.0 generate-002"}, + {'id': 'imagen-3.0-generate-002', 'name': 'imagen-3.0 generate-002'}, ] - elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": + elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': # TODO - get models from comfyui - headers = { - "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}" - } + headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} r = requests.get( - url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info", + url=f'{request.app.state.config.COMFYUI_BASE_URL}/object_info', headers=headers, ) info = r.json() @@ -409,52 +367,46 @@ def get_models(request: Request, user=Depends(get_verified_user)): model_node_id = None for node in request.app.state.config.COMFYUI_WORKFLOW_NODES: - if node["type"] == "model": - if node["node_ids"]: - model_node_id = node["node_ids"][0] + if node['type'] == 'model': + if node['node_ids']: + model_node_id = node['node_ids'][0] break if model_node_id: model_list_key = None - log.info(workflow[model_node_id]["class_type"]) - for key in info[workflow[model_node_id]["class_type"]]["input"][ - "required" - ]: - if "_name" in key: + log.info(workflow[model_node_id]['class_type']) + for key in info[workflow[model_node_id]['class_type']]['input']['required']: + if '_name' in key: model_list_key = key break if model_list_key: return list( map( - lambda model: {"id": model, "name": model}, - info[workflow[model_node_id]["class_type"]]["input"][ - "required" - ][model_list_key][0], + lambda model: {'id': model, 'name': model}, + info[workflow[model_node_id]['class_type']]['input']['required'][model_list_key][0], ) ) else: return list( map( - lambda model: {"id": model, "name": model}, - info["CheckpointLoaderSimple"]["input"]["required"][ - "ckpt_name" - ][0], + lambda model: {'id': model, 'name': model}, + info['CheckpointLoaderSimple']['input']['required']['ckpt_name'][0], ) ) elif ( - request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111" - or request.app.state.config.IMAGE_GENERATION_ENGINE == "" + request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111' + or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): r = requests.get( - url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models", - headers={"authorization": get_automatic1111_api_auth(request)}, + url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models', + headers={'authorization': get_automatic1111_api_auth(request)}, ) models = r.json() return list( map( - lambda model: {"id": model["title"], "name": model["model_name"]}, + lambda model: {'id': model['title'], 'name': model['model_name']}, models, ) ) @@ -477,30 +429,30 @@ class CreateImageForm(BaseModel): def get_image_data(data: str, headers=None): try: - if data.startswith("http://") or data.startswith("https://"): + if data.startswith('http://') or data.startswith('https://'): if headers: r = requests.get(data, headers=headers) else: r = requests.get(data) r.raise_for_status() - if r.headers["content-type"].split("/")[0] == "image": - mime_type = r.headers["content-type"] + if r.headers['content-type'].split('/')[0] == 'image': + mime_type = r.headers['content-type'] return r.content, mime_type else: - log.error("Url does not point to an image.") + log.error('Url does not point to an image.') return None else: - if "," in data: - header, encoded = data.split(",", 1) - mime_type = header.split(";")[0].lstrip("data:") + if ',' in data: + header, encoded = data.split(',', 1) + mime_type = header.split(';')[0].lstrip('data:') img_data = base64.b64decode(encoded) else: - mime_type = "image/png" + mime_type = 'image/png' img_data = base64.b64decode(data) return img_data, mime_type except Exception as e: - log.exception(f"Error loading image data: {e}") + log.exception(f'Error loading image data: {e}') return None, None @@ -508,9 +460,9 @@ def upload_image(request, image_data, content_type, metadata, user, db=None): image_format = mimetypes.guess_extension(content_type) file = UploadFile( file=io.BytesIO(image_data), - filename=f"generated-image{image_format}", # will be converted to a unique ID on upload_file + filename=f'generated-image{image_format}', # will be converted to a unique ID on upload_file headers={ - "content-type": content_type, + 'content-type': content_type, }, ) file_item = upload_file_handler( @@ -523,8 +475,8 @@ def upload_image(request, image_data, content_type, metadata, user, db=None): if file_item and file_item.id: # If chat_id and message_id are provided in metadata, link the file to the chat message - chat_id = metadata.get("chat_id") - message_id = metadata.get("message_id") + chat_id = metadata.get('chat_id') + message_id = metadata.get('message_id') if chat_id and message_id: Chats.insert_chat_files( @@ -535,22 +487,20 @@ def upload_image(request, image_data, content_type, metadata, user, db=None): db=db, ) - url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) + url = request.app.url_path_for('get_file_content_by_id', id=file_item.id) return file_item, url -@router.post("/generations") -async def generate_images( - request: Request, form_data: CreateImageForm, user=Depends(get_verified_user) -): +@router.post('/generations') +async def generate_images(request: Request, form_data: CreateImageForm, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_IMAGE_GENERATION: raise HTTPException( status_code=403, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if user.role != "admin" and not has_permission( - user.id, "features.image_generation", request.app.state.config.USER_PERMISSIONS + if user.role != 'admin' and not has_permission( + user.id, 'features.image_generation', request.app.state.config.USER_PERMISSIONS ): raise HTTPException( status_code=403, @@ -570,17 +520,14 @@ async def image_generations( # This is only relevant when the user has set IMAGE_SIZE to 'auto' with an # image model other than gpt-image-1, which is warned about on settings save - size = "512x512" - if ( - request.app.state.config.IMAGE_SIZE - and "x" in request.app.state.config.IMAGE_SIZE - ): + size = '512x512' + if request.app.state.config.IMAGE_SIZE and 'x' in request.app.state.config.IMAGE_SIZE: size = request.app.state.config.IMAGE_SIZE - if form_data.size and "x" in form_data.size: + if form_data.size and 'x' in form_data.size: size = form_data.size - width, height = tuple(map(int, size.split("x"))) + width, height = tuple(map(int, size.split('x'))) metadata = metadata or {} @@ -588,36 +535,31 @@ async def image_generations( r = None try: - if request.app.state.config.IMAGE_GENERATION_ENGINE == "openai": - + if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai': headers = { - "Authorization": f"Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}", - "Content-Type": "application/json", + 'Authorization': f'Bearer {request.app.state.config.IMAGES_OPENAI_API_KEY}', + 'Content-Type': 'application/json', } if ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) - url = f"{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations" + url = f'{request.app.state.config.IMAGES_OPENAI_API_BASE_URL}/images/generations' if request.app.state.config.IMAGES_OPENAI_API_VERSION: - url = f"{url}?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}" + url = f'{url}?api-version={request.app.state.config.IMAGES_OPENAI_API_VERSION}' data = { - "model": model, - "prompt": form_data.prompt, - "n": form_data.n, - "size": ( - form_data.size - if form_data.size - else request.app.state.config.IMAGE_SIZE - ), + 'model': model, + 'prompt': form_data.prompt, + 'n': form_data.n, + 'size': (form_data.size if form_data.size else request.app.state.config.IMAGE_SIZE), **( {} if re.match( IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN, request.app.state.config.IMAGE_GENERATION_MODEL, ) - else {"response_format": "b64_json"} + else {'response_format': 'b64_json'} ), **( {} @@ -639,53 +581,48 @@ async def image_generations( images = [] - for image in res["data"]: - if image_url := image.get("url", None): + for image in res['data']: + if image_url := image.get('url', None): image_data, content_type = get_image_data( image_url, - {k: v for k, v in headers.items() if k != "Content-Type"}, + {k: v for k, v in headers.items() if k != 'Content-Type'}, ) else: - image_data, content_type = get_image_data(image["b64_json"]) + image_data, content_type = get_image_data(image['b64_json']) - _, url = upload_image( - request, image_data, content_type, {**data, **metadata}, user - ) - images.append({"url": url}) + _, url = upload_image(request, image_data, content_type, {**data, **metadata}, user) + images.append({'url': url}) return images - elif request.app.state.config.IMAGE_GENERATION_ENGINE == "gemini": + elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'gemini': headers = { - "Content-Type": "application/json", - "x-goog-api-key": request.app.state.config.IMAGES_GEMINI_API_KEY, + 'Content-Type': 'application/json', + 'x-goog-api-key': request.app.state.config.IMAGES_GEMINI_API_KEY, } data = {} if ( - request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == "" - or request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == "predict" + request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == '' + or request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == 'predict' ): - model = f"{model}:predict" + model = f'{model}:predict' data = { - "instances": {"prompt": form_data.prompt}, - "parameters": { - "sampleCount": form_data.n, - "outputOptions": {"mimeType": "image/png"}, + 'instances': {'prompt': form_data.prompt}, + 'parameters': { + 'sampleCount': form_data.n, + 'outputOptions': {'mimeType': 'image/png'}, }, } - elif ( - request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD - == "generateContent" - ): - model = f"{model}:generateContent" - data = {"contents": [{"parts": [{"text": form_data.prompt}]}]} + elif request.app.state.config.IMAGES_GEMINI_ENDPOINT_METHOD == 'generateContent': + model = f'{model}:generateContent' + data = {'contents': [{'parts': [{'text': form_data.prompt}]}]} # Use asyncio.to_thread for the requests.post call r = await asyncio.to_thread( requests.post, - url=f"{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}", + url=f'{request.app.state.config.IMAGES_GEMINI_API_BASE_URL}/models/{model}', json=data, headers=headers, ) @@ -695,22 +632,16 @@ async def image_generations( images = [] - if model.endswith(":predict"): - for image in res["predictions"]: - image_data, content_type = get_image_data( - image["bytesBase64Encoded"] - ) - _, url = upload_image( - request, image_data, content_type, {**data, **metadata}, user - ) - images.append({"url": url}) - elif model.endswith(":generateContent"): - for image in res["candidates"]: - for part in image["content"]["parts"]: - if part.get("inlineData", {}).get("data"): - image_data, content_type = get_image_data( - part["inlineData"]["data"] - ) + if model.endswith(':predict'): + for image in res['predictions']: + image_data, content_type = get_image_data(image['bytesBase64Encoded']) + _, url = upload_image(request, image_data, content_type, {**data, **metadata}, user) + images.append({'url': url}) + elif model.endswith(':generateContent'): + for image in res['candidates']: + for part in image['content']['parts']: + if part.get('inlineData', {}).get('data'): + image_data, content_type = get_image_data(part['inlineData']['data']) _, url = upload_image( request, image_data, @@ -718,37 +649,30 @@ async def image_generations( {**data, **metadata}, user, ) - images.append({"url": url}) + images.append({'url': url}) return images - elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui": + elif request.app.state.config.IMAGE_GENERATION_ENGINE == 'comfyui': data = { - "prompt": form_data.prompt, - "width": width, - "height": height, - "n": form_data.n, + 'prompt': form_data.prompt, + 'width': width, + 'height': height, + 'n': form_data.n, } - if ( - request.app.state.config.IMAGE_STEPS is not None - or form_data.steps is not None - ): - data["steps"] = ( - form_data.steps - if form_data.steps is not None - else request.app.state.config.IMAGE_STEPS - ) + if request.app.state.config.IMAGE_STEPS is not None or form_data.steps is not None: + data['steps'] = form_data.steps if form_data.steps is not None else request.app.state.config.IMAGE_STEPS if form_data.negative_prompt is not None: - data["negative_prompt"] = form_data.negative_prompt + data['negative_prompt'] = form_data.negative_prompt form_data = ComfyUICreateImageForm( **{ - "workflow": ComfyUIWorkflow( + 'workflow': ComfyUIWorkflow( **{ - "workflow": request.app.state.config.COMFYUI_WORKFLOW, - "nodes": request.app.state.config.COMFYUI_WORKFLOW_NODES, + 'workflow': request.app.state.config.COMFYUI_WORKFLOW, + 'nodes': request.app.state.config.COMFYUI_WORKFLOW_NODES, } ), **data, @@ -761,18 +685,16 @@ async def image_generations( request.app.state.config.COMFYUI_BASE_URL, request.app.state.config.COMFYUI_API_KEY, ) - log.debug(f"res: {res}") + log.debug(f'res: {res}') images = [] - for image in res["data"]: + for image in res['data']: headers = None if request.app.state.config.COMFYUI_API_KEY: - headers = { - "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}" - } + headers = {'Authorization': f'Bearer {request.app.state.config.COMFYUI_API_KEY}'} - image_data, content_type = get_image_data(image["url"], headers) + image_data, content_type = get_image_data(image['url'], headers) _, url = upload_image( request, image_data, @@ -780,34 +702,27 @@ async def image_generations( {**form_data.model_dump(exclude_none=True), **metadata}, user, ) - images.append({"url": url}) + images.append({'url': url}) return images elif ( - request.app.state.config.IMAGE_GENERATION_ENGINE == "automatic1111" - or request.app.state.config.IMAGE_GENERATION_ENGINE == "" + request.app.state.config.IMAGE_GENERATION_ENGINE == 'automatic1111' + or request.app.state.config.IMAGE_GENERATION_ENGINE == '' ): if form_data.model: set_image_model(request, form_data.model) data = { - "prompt": form_data.prompt, - "batch_size": form_data.n, - "width": width, - "height": height, + 'prompt': form_data.prompt, + 'batch_size': form_data.n, + 'width': width, + 'height': height, } - if ( - request.app.state.config.IMAGE_STEPS is not None - or form_data.steps is not None - ): - data["steps"] = ( - form_data.steps - if form_data.steps is not None - else request.app.state.config.IMAGE_STEPS - ) + if request.app.state.config.IMAGE_STEPS is not None or form_data.steps is not None: + data['steps'] = form_data.steps if form_data.steps is not None else request.app.state.config.IMAGE_STEPS if form_data.negative_prompt is not None: - data["negative_prompt"] = form_data.negative_prompt + data['negative_prompt'] = form_data.negative_prompt if request.app.state.config.AUTOMATIC1111_PARAMS: data = {**data, **request.app.state.config.AUTOMATIC1111_PARAMS} @@ -815,33 +730,33 @@ async def image_generations( # Use asyncio.to_thread for the requests.post call r = await asyncio.to_thread( requests.post, - url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img", + url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img', json=data, - headers={"authorization": get_automatic1111_api_auth(request)}, + headers={'authorization': get_automatic1111_api_auth(request)}, ) res = r.json() - log.debug(f"res: {res}") + log.debug(f'res: {res}') images = [] - for image in res["images"]: + for image in res['images']: image_data, content_type = get_image_data(image) _, url = upload_image( request, image_data, content_type, - {**data, "info": res["info"], **metadata}, + {**data, 'info': res['info'], **metadata}, user, ) - images.append({"url": url}) + images.append({'url': url}) return images except Exception as e: error = e if r != None: data = r.json() - if "error" in data: - error = data["error"]["message"] + if 'error' in data: + error = data['error']['message'] raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error)) @@ -855,7 +770,7 @@ class EditImageForm(BaseModel): background: Optional[str] = None -@router.post("/edit") +@router.post('/edit') async def image_edits( request: Request, form_data: EditImageForm, @@ -866,42 +781,33 @@ async def image_edits( width, height = None, None metadata = metadata or {} - if ( - request.app.state.config.IMAGE_EDIT_SIZE - and "x" in request.app.state.config.IMAGE_EDIT_SIZE - ) or (form_data.size and "x" in form_data.size): - size = ( - form_data.size - if form_data.size - else request.app.state.config.IMAGE_EDIT_SIZE - ) - width, height = tuple(map(int, size.split("x"))) + if (request.app.state.config.IMAGE_EDIT_SIZE and 'x' in request.app.state.config.IMAGE_EDIT_SIZE) or ( + form_data.size and 'x' in form_data.size + ): + size = form_data.size if form_data.size else request.app.state.config.IMAGE_EDIT_SIZE + width, height = tuple(map(int, size.split('x'))) - model = ( - request.app.state.config.IMAGE_EDIT_MODEL - if form_data.model is None - else form_data.model - ) + model = request.app.state.config.IMAGE_EDIT_MODEL if form_data.model is None else form_data.model try: async def load_url_image(data): - if data.startswith("data:"): + if data.startswith('data:'): return data - if data.startswith("http://") or data.startswith("https://"): + if data.startswith('http://') or data.startswith('https://'): # Validate URL to prevent SSRF attacks against local/private networks validate_url(data) r = await asyncio.to_thread(requests.get, data) r.raise_for_status() - image_data = base64.b64encode(r.content).decode("utf-8") - return f"data:{r.headers['content-type']};base64,{image_data}" + image_data = base64.b64encode(r.content).decode('utf-8') + return f'data:{r.headers["content-type"]};base64,{image_data}' else: file_id = None - if data.startswith("/api/v1/files"): - file_id = data.split("/api/v1/files/")[1].split("/content")[0] + if data.startswith('/api/v1/files'): + file_id = data.split('/api/v1/files/')[1].split('/content')[0] else: file_id = data @@ -909,12 +815,12 @@ async def load_url_image(data): if isinstance(file_response, FileResponse): file_path = file_response.path - with open(file_path, "rb") as f: + with open(file_path, 'rb') as f: file_bytes = f.read() - image_data = base64.b64encode(file_bytes).decode("utf-8") + 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 f'data:{mime_type};base64,{image_data}' return data # Load image(s) from URL(s) if necessary @@ -922,51 +828,47 @@ async def load_url_image(data): form_data.image = await load_url_image(form_data.image) elif isinstance(form_data.image, list): # Load all images in parallel for better performance - form_data.image = list( - await asyncio.gather(*[load_url_image(img) for img in form_data.image]) - ) + form_data.image = list(await asyncio.gather(*[load_url_image(img) for img in form_data.image])) except Exception as e: raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e)) - def get_image_file_item(base64_string, param_name="image"): + def get_image_file_item(base64_string, param_name='image'): data = base64_string - header, encoded = data.split(",", 1) - mime_type = header.split(";")[0].lstrip("data:") + header, encoded = data.split(',', 1) + mime_type = header.split(';')[0].lstrip('data:') image_data = base64.b64decode(encoded) return ( param_name, ( - f"{uuid.uuid4()}.png", + f'{uuid.uuid4()}.png', io.BytesIO(image_data), - mime_type if mime_type else "image/png", + mime_type if mime_type else 'image/png', ), ) r = None try: - if request.app.state.config.IMAGE_EDIT_ENGINE == "openai": + if request.app.state.config.IMAGE_EDIT_ENGINE == 'openai': headers = { - "Authorization": f"Bearer {request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY}", + 'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_OPENAI_API_KEY}', } if ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) data = { - "model": model, - "prompt": form_data.prompt, - **({"n": form_data.n} if form_data.n else {}), - **({"size": size} if size else {}), - **( - {"background": form_data.background} if form_data.background else {} - ), + 'model': model, + 'prompt': form_data.prompt, + **({'n': form_data.n} if form_data.n else {}), + **({'size': size} if size else {}), + **({'background': form_data.background} if form_data.background else {}), **( {} if re.match( IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN, request.app.state.config.IMAGE_EDIT_MODEL, ) - else {"response_format": "b64_json"} + else {'response_format': 'b64_json'} ), } @@ -975,16 +877,16 @@ def get_image_file_item(base64_string, param_name="image"): files = [get_image_file_item(form_data.image)] elif isinstance(form_data.image, list): for img in form_data.image: - files.append(get_image_file_item(img, "image[]")) + files.append(get_image_file_item(img, 'image[]')) - url_search_params = "" + url_search_params = '' if request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION: - url_search_params += f"?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}" + url_search_params += f'?api-version={request.app.state.config.IMAGES_EDIT_OPENAI_API_VERSION}' # Use asyncio.to_thread for the requests.post call r = await asyncio.to_thread( requests.post, - url=f"{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}", + url=f'{request.app.state.config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}', headers=headers, files=files, data=data, @@ -994,46 +896,44 @@ def get_image_file_item(base64_string, param_name="image"): res = r.json() images = [] - for image in res["data"]: - if image_url := image.get("url", None): + for image in res['data']: + if image_url := image.get('url', None): image_data, content_type = get_image_data( image_url, - {k: v for k, v in headers.items() if k != "Content-Type"}, + {k: v for k, v in headers.items() if k != 'Content-Type'}, ) else: - image_data, content_type = get_image_data(image["b64_json"]) + image_data, content_type = get_image_data(image['b64_json']) - _, url = upload_image( - request, image_data, content_type, {**data, **metadata}, user - ) - images.append({"url": url}) + _, url = upload_image(request, image_data, content_type, {**data, **metadata}, user) + images.append({'url': url}) return images - elif request.app.state.config.IMAGE_EDIT_ENGINE == "gemini": + elif request.app.state.config.IMAGE_EDIT_ENGINE == 'gemini': headers = { - "Content-Type": "application/json", - "x-goog-api-key": request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, + 'Content-Type': 'application/json', + 'x-goog-api-key': request.app.state.config.IMAGES_EDIT_GEMINI_API_KEY, } - model = f"{model}:generateContent" - data = {"contents": [{"parts": [{"text": form_data.prompt}]}]} + model = f'{model}:generateContent' + data = {'contents': [{'parts': [{'text': form_data.prompt}]}]} if isinstance(form_data.image, str): - data["contents"][0]["parts"].append( + data['contents'][0]['parts'].append( { - "inline_data": { - "mime_type": "image/png", - "data": form_data.image.split(",", 1)[1], + 'inline_data': { + 'mime_type': 'image/png', + 'data': form_data.image.split(',', 1)[1], } } ) elif isinstance(form_data.image, list): - data["contents"][0]["parts"].extend( + data['contents'][0]['parts'].extend( [ { - "inline_data": { - "mime_type": "image/png", - "data": image.split(",", 1)[1], + 'inline_data': { + 'mime_type': 'image/png', + 'data': image.split(',', 1)[1], } } for image in form_data.image @@ -1043,7 +943,7 @@ def get_image_file_item(base64_string, param_name="image"): # Use asyncio.to_thread for the requests.post call r = await asyncio.to_thread( requests.post, - url=f"{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}", + url=f'{request.app.state.config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}', json=data, headers=headers, ) @@ -1052,12 +952,10 @@ def get_image_file_item(base64_string, param_name="image"): res = r.json() images = [] - for image in res["candidates"]: - for part in image["content"]["parts"]: - if part.get("inlineData", {}).get("data"): - image_data, content_type = get_image_data( - part["inlineData"]["data"] - ) + for image in res['candidates']: + for part in image['content']['parts']: + if part.get('inlineData', {}).get('data'): + image_data, content_type = get_image_data(part['inlineData']['data']) _, url = upload_image( request, image_data, @@ -1065,11 +963,11 @@ def get_image_file_item(base64_string, param_name="image"): {**data, **metadata}, user, ) - images.append({"url": url}) + images.append({'url': url}) return images - elif request.app.state.config.IMAGE_EDIT_ENGINE == "comfyui": + elif request.app.state.config.IMAGE_EDIT_ENGINE == 'comfyui': try: files = [] if isinstance(form_data.image, str): @@ -1086,25 +984,25 @@ def get_image_file_item(base64_string, param_name="image"): request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, ) - comfyui_images.append(res.get("name", file_item[1][0])) + comfyui_images.append(res.get('name', file_item[1][0])) except Exception as e: - log.debug(f"Error uploading images to ComfyUI: {e}") - raise Exception("Failed to upload images to ComfyUI.") + log.debug(f'Error uploading images to ComfyUI: {e}') + raise Exception('Failed to upload images to ComfyUI.') data = { - "image": comfyui_images, - "prompt": form_data.prompt, - **({"width": width} if width is not None else {}), - **({"height": height} if height is not None else {}), - **({"n": form_data.n} if form_data.n else {}), + 'image': comfyui_images, + 'prompt': form_data.prompt, + **({'width': width} if width is not None else {}), + **({'height': height} if height is not None else {}), + **({'n': form_data.n} if form_data.n else {}), } form_data = ComfyUIEditImageForm( **{ - "workflow": ComfyUIWorkflow( + 'workflow': ComfyUIWorkflow( **{ - "workflow": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, - "nodes": request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, + 'workflow': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW, + 'nodes': request.app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES, } ), **data, @@ -1117,27 +1015,25 @@ def get_image_file_item(base64_string, param_name="image"): request.app.state.config.IMAGES_EDIT_COMFYUI_BASE_URL, request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY, ) - log.debug(f"res: {res}") + log.debug(f'res: {res}') image_urls = set() - for image in res["data"]: - image_urls.add(image["url"]) + for image in res['data']: + image_urls.add(image['url']) image_urls = list(image_urls) # Prioritize output type URLs if available - output_type_urls = [url for url in image_urls if "type=output" in url] + output_type_urls = [url for url in image_urls if 'type=output' in url] if output_type_urls: image_urls = output_type_urls - log.debug(f"Image URLs: {image_urls}") + log.debug(f'Image URLs: {image_urls}') images = [] for image_url in image_urls: headers = None if request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY: - headers = { - "Authorization": f"Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}" - } + headers = {'Authorization': f'Bearer {request.app.state.config.IMAGES_EDIT_COMFYUI_API_KEY}'} image_data, content_type = get_image_data(image_url, headers) _, url = upload_image( @@ -1147,7 +1043,7 @@ def get_image_file_item(base64_string, param_name="image"): {**form_data.model_dump(exclude_none=True), **metadata}, user, ) - images.append({"url": url}) + images.append({'url': url}) return images except Exception as e: @@ -1156,8 +1052,8 @@ def get_image_file_item(base64_string, param_name="image"): data = r.text try: data = json.loads(data) - if "error" in data: - error = data["error"]["message"] + if 'error' in data: + error = data['error']['message'] except Exception: error = data diff --git a/backend/open_webui/routers/knowledge.py b/backend/open_webui/routers/knowledge.py index 8bc58b4371..ead782cdbf 100644 --- a/backend/open_webui/routers/knowledge.py +++ b/backend/open_webui/routers/knowledge.py @@ -6,6 +6,7 @@ import logging import io import zipfile +from urllib.parse import quote from sqlalchemy.orm import Session from open_webui.internal.db import get_session @@ -50,7 +51,9 @@ # Knowledge Base Embedding ############################ -KNOWLEDGE_BASES_COLLECTION = "knowledge-bases" +# Knowledge that sits unread serves no one. Let what is +# stored here find the ones who need it. +KNOWLEDGE_BASES_COLLECTION = 'knowledge-bases' async def embed_knowledge_base_metadata( @@ -61,24 +64,24 @@ async def embed_knowledge_base_metadata( ) -> bool: """Generate and store embedding for knowledge base.""" try: - content = f"{name}\n\n{description}" if description else name + content = f'{name}\n\n{description}' if description else name embedding = await request.app.state.EMBEDDING_FUNCTION(content) VECTOR_DB_CLIENT.upsert( collection_name=KNOWLEDGE_BASES_COLLECTION, items=[ { - "id": knowledge_base_id, - "text": content, - "vector": embedding, - "metadata": { - "knowledge_base_id": knowledge_base_id, + 'id': knowledge_base_id, + 'text': content, + 'vector': embedding, + 'metadata': { + 'knowledge_base_id': knowledge_base_id, }, } ], ) return True except Exception as e: - log.error(f"Failed to embed knowledge base {knowledge_base_id}: {e}") + log.error(f'Failed to embed knowledge base {knowledge_base_id}: {e}') return False @@ -91,7 +94,7 @@ def remove_knowledge_base_metadata_embedding(knowledge_base_id: str) -> bool: ) return True except Exception as e: - log.debug(f"Failed to remove embedding for {knowledge_base_id}: {e}") + log.debug(f'Failed to remove embedding for {knowledge_base_id}: {e}') return False @@ -104,7 +107,7 @@ class KnowledgeAccessListResponse(BaseModel): total: int -@router.get("/", response_model=KnowledgeAccessListResponse) +@router.get('/', response_model=KnowledgeAccessListResponse) async def get_knowledge_bases( page: Optional[int] = 1, user=Depends(get_verified_user), @@ -118,23 +121,21 @@ async def get_knowledge_bases( groups = Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} - if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: if groups: - filter["group_ids"] = [group.id for group in groups] + filter['group_ids'] = [group.id for group in groups] - filter["user_id"] = user.id + filter['user_id'] = user.id - result = Knowledges.search_knowledge_bases( - user.id, filter=filter, skip=skip, limit=limit, db=db - ) + result = Knowledges.search_knowledge_bases(user.id, filter=filter, skip=skip, limit=limit, db=db) # Batch-fetch writable knowledge IDs in a single query instead of N has_access calls knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items] writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_ids=knowledge_base_ids, - permission="write", + permission='write', user_group_ids=user_group_ids, db=db, ) @@ -145,7 +146,7 @@ async def get_knowledge_bases( **knowledge_base.model_dump(), write_access=( user.id == knowledge_base.user_id - or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or knowledge_base.id in writable_knowledge_base_ids ), ) @@ -155,7 +156,7 @@ async def get_knowledge_bases( ) -@router.get("/search", response_model=KnowledgeAccessListResponse) +@router.get('/search', response_model=KnowledgeAccessListResponse) async def search_knowledge_bases( query: Optional[str] = None, view_option: Optional[str] = None, @@ -169,30 +170,28 @@ async def search_knowledge_bases( filter = {} if query: - filter["query"] = query + filter['query'] = query if view_option: - filter["view_option"] = view_option + filter['view_option'] = view_option groups = Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} - if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: if groups: - filter["group_ids"] = [group.id for group in groups] + filter['group_ids'] = [group.id for group in groups] - filter["user_id"] = user.id + filter['user_id'] = user.id - result = Knowledges.search_knowledge_bases( - user.id, filter=filter, skip=skip, limit=limit, db=db - ) + result = Knowledges.search_knowledge_bases(user.id, filter=filter, skip=skip, limit=limit, db=db) # Batch-fetch writable knowledge IDs in a single query instead of N has_access calls knowledge_base_ids = [knowledge_base.id for knowledge_base in result.items] writable_knowledge_base_ids = AccessGrants.get_accessible_resource_ids( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_ids=knowledge_base_ids, - permission="write", + permission='write', user_group_ids=user_group_ids, db=db, ) @@ -203,7 +202,7 @@ async def search_knowledge_bases( **knowledge_base.model_dump(), write_access=( user.id == knowledge_base.user_id - or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or knowledge_base.id in writable_knowledge_base_ids ), ) @@ -213,7 +212,7 @@ async def search_knowledge_bases( ) -@router.get("/search/files", response_model=KnowledgeFileListResponse) +@router.get('/search/files', response_model=KnowledgeFileListResponse) async def search_knowledge_files( query: Optional[str] = None, page: Optional[int] = 1, @@ -226,17 +225,15 @@ async def search_knowledge_files( filter = {} if query: - filter["query"] = query + filter['query'] = query groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: - filter["group_ids"] = [group.id for group in groups] + filter['group_ids'] = [group.id for group in groups] - filter["user_id"] = user.id + filter['user_id'] = user.id - return Knowledges.search_knowledge_files( - filter=filter, skip=skip, limit=limit, db=db - ) + return Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit, db=db) ############################ @@ -244,7 +241,7 @@ async def search_knowledge_files( ############################ -@router.post("/create", response_model=Optional[KnowledgeResponse]) +@router.post('/create', response_model=Optional[KnowledgeResponse]) async def create_new_knowledge( request: Request, form_data: KnowledgeForm, @@ -254,8 +251,8 @@ async def create_new_knowledge( # Database operations (has_permission, filter_allowed_access_grants, insert_new_knowledge) manage their own sessions. # This prevents holding a connection during embed_knowledge_base_metadata() # which makes external embedding API calls (1-5+ seconds). - if user.role != "admin" and not has_permission( - user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS + if user.role != 'admin' and not has_permission( + user.id, 'workspace.knowledge', request.app.state.config.USER_PERMISSIONS ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -267,7 +264,7 @@ async def create_new_knowledge( user.id, user.role, form_data.access_grants, - "sharing.public_knowledge", + 'sharing.public_knowledge', ) knowledge = Knowledges.insert_new_knowledge(user.id, form_data) @@ -293,13 +290,13 @@ async def create_new_knowledge( ############################ -@router.post("/reindex", response_model=bool) +@router.post('/reindex', response_model=bool) async def reindex_knowledge_files( request: Request, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin": + if user.role != 'admin': raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, @@ -307,18 +304,16 @@ async def reindex_knowledge_files( knowledge_bases = Knowledges.get_knowledge_bases(db=db) - log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases") + log.info(f'Starting reindexing for {len(knowledge_bases)} knowledge bases') for knowledge_base in knowledge_bases: try: files = Knowledges.get_files_by_id(knowledge_base.id, db=db) try: if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id): - VECTOR_DB_CLIENT.delete_collection( - collection_name=knowledge_base.id - ) + VECTOR_DB_CLIENT.delete_collection(collection_name=knowledge_base.id) except Exception as e: - log.error(f"Error deleting collection {knowledge_base.id}: {str(e)}") + log.error(f'Error deleting collection {knowledge_base.id}: {str(e)}') continue # Skip, don't raise failed_files = [] @@ -327,32 +322,26 @@ async def reindex_knowledge_files( await run_in_threadpool( process_file, request, - ProcessFileForm( - file_id=file.id, collection_name=knowledge_base.id - ), + ProcessFileForm(file_id=file.id, collection_name=knowledge_base.id), user=user, db=db, ) except Exception as e: - log.error( - f"Error processing file {file.filename} (ID: {file.id}): {str(e)}" - ) - failed_files.append({"file_id": file.id, "error": str(e)}) + log.error(f'Error processing file {file.filename} (ID: {file.id}): {str(e)}') + failed_files.append({'file_id': file.id, 'error': str(e)}) continue except Exception as e: - log.error(f"Error processing knowledge base {knowledge_base.id}: {str(e)}") + log.error(f'Error processing knowledge base {knowledge_base.id}: {str(e)}') # Don't raise, just continue continue if failed_files: - log.warning( - f"Failed to process {len(failed_files)} files in knowledge base {knowledge_base.id}" - ) + log.warning(f'Failed to process {len(failed_files)} files in knowledge base {knowledge_base.id}') for failed in failed_files: - log.warning(f"File ID: {failed['file_id']}, Error: {failed['error']}") + log.warning(f'File ID: {failed["file_id"]}, Error: {failed["error"]}') - log.info(f"Reindexing completed.") + log.info(f'Reindexing completed.') return True @@ -361,7 +350,7 @@ async def reindex_knowledge_files( ############################ -@router.post("/metadata/reindex", response_model=dict) +@router.post('/metadata/reindex', response_model=dict) async def reindex_knowledge_base_metadata_embeddings( request: Request, user=Depends(get_admin_user), @@ -374,15 +363,15 @@ async def reindex_knowledge_base_metadata_embeddings( this entire operation would exhaust the connection pool. """ knowledge_bases = Knowledges.get_knowledge_bases() - log.info(f"Reindexing embeddings for {len(knowledge_bases)} knowledge bases") + log.info(f'Reindexing embeddings for {len(knowledge_bases)} knowledge bases') success_count = 0 for kb in knowledge_bases: if await embed_knowledge_base_metadata(request, kb.id, kb.name, kb.description): success_count += 1 - log.info(f"Embedding reindex complete: {success_count}/{len(knowledge_bases)}") - return {"total": len(knowledge_bases), "success": success_count} + log.info(f'Embedding reindex complete: {success_count}/{len(knowledge_bases)}') + return {'total': len(knowledge_bases), 'success': success_count} ############################ @@ -395,35 +384,32 @@ class KnowledgeFilesResponse(KnowledgeResponse): write_access: Optional[bool] = False -@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse]) -async def get_knowledge_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{id}', response_model=Optional[KnowledgeFilesResponse]) +async def get_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if knowledge: if ( - user.role == "admin" + user.role == 'admin' or knowledge.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="read", + permission='read', db=db, ) ): - return KnowledgeFilesResponse( **knowledge.model_dump(), write_access=( user.id == knowledge.user_id - or (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', db=db, ) ), @@ -445,7 +431,7 @@ async def get_knowledge_by_id( ############################ -@router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse]) +@router.post('/{id}/update', response_model=Optional[KnowledgeFilesResponse]) async def update_knowledge_by_id( request: Request, id: str, @@ -467,11 +453,11 @@ async def update_knowledge_by_id( knowledge.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -483,7 +469,7 @@ async def update_knowledge_by_id( user.id, user.role, form_data.access_grants, - "sharing.public_knowledge", + 'sharing.public_knowledge', ) knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data) @@ -515,7 +501,7 @@ class KnowledgeAccessGrantsForm(BaseModel): access_grants: list[dict] -@router.post("/{id}/access/update", response_model=Optional[KnowledgeFilesResponse]) +@router.post('/{id}/access/update', response_model=Optional[KnowledgeFilesResponse]) async def update_knowledge_access_by_id( request: Request, id: str, @@ -534,12 +520,12 @@ async def update_knowledge_access_by_id( knowledge.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -551,10 +537,10 @@ async def update_knowledge_access_by_id( user.id, user.role, form_data.access_grants, - "sharing.public_knowledge", + 'sharing.public_knowledge', ) - AccessGrants.set_access_grants("knowledge", id, form_data.access_grants, db=db) + AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db) return KnowledgeFilesResponse( **Knowledges.get_knowledge_by_id(id=id, db=db).model_dump(), @@ -567,7 +553,7 @@ async def update_knowledge_access_by_id( ############################ -@router.get("/{id}/files", response_model=KnowledgeFileListResponse) +@router.get('/{id}/files', response_model=KnowledgeFileListResponse) async def get_knowledge_files_by_id( id: str, query: Optional[str] = None, @@ -578,7 +564,6 @@ async def get_knowledge_files_by_id( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( @@ -587,13 +572,13 @@ async def get_knowledge_files_by_id( ) if not ( - user.role == "admin" + user.role == 'admin' or knowledge.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="read", + permission='read', db=db, ) ): @@ -609,17 +594,15 @@ async def get_knowledge_files_by_id( filter = {} if query: - filter["query"] = query + filter['query'] = query if view_option: - filter["view_option"] = view_option + filter['view_option'] = view_option if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction - return Knowledges.search_files_by_id( - id, user.id, filter=filter, skip=skip, limit=limit, db=db - ) + return Knowledges.search_files_by_id(id, user.id, filter=filter, skip=skip, limit=limit, db=db) ############################ @@ -631,7 +614,7 @@ class KnowledgeFileIdForm(BaseModel): file_id: str -@router.post("/{id}/file/add", response_model=Optional[KnowledgeFilesResponse]) +@router.post('/{id}/file/add', response_model=Optional[KnowledgeFilesResponse]) def add_file_to_knowledge_by_id( request: Request, id: str, @@ -650,12 +633,12 @@ def add_file_to_knowledge_by_id( knowledge.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -684,9 +667,7 @@ def add_file_to_knowledge_by_id( ) # Add file to knowledge base - Knowledges.add_file_to_knowledge_by_id( - knowledge_id=id, file_id=form_data.file_id, user_id=user.id, db=db - ) + Knowledges.add_file_to_knowledge_by_id(knowledge_id=id, file_id=form_data.file_id, user_id=user.id, db=db) except Exception as e: log.debug(e) raise HTTPException( @@ -706,7 +687,7 @@ def add_file_to_knowledge_by_id( ) -@router.post("/{id}/file/update", response_model=Optional[KnowledgeFilesResponse]) +@router.post('/{id}/file/update', response_model=Optional[KnowledgeFilesResponse]) def update_file_from_knowledge_by_id( request: Request, id: str, @@ -725,14 +706,13 @@ def update_file_from_knowledge_by_id( knowledge.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): - raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -753,9 +733,7 @@ def update_file_from_knowledge_by_id( ) # Remove content from the vector database - VECTOR_DB_CLIENT.delete( - collection_name=knowledge.id, filter={"file_id": form_data.file_id} - ) + VECTOR_DB_CLIENT.delete(collection_name=knowledge.id, filter={'file_id': form_data.file_id}) # Add content to the vector database try: @@ -788,7 +766,7 @@ def update_file_from_knowledge_by_id( ############################ -@router.post("/{id}/file/remove", response_model=Optional[KnowledgeFilesResponse]) +@router.post('/{id}/file/remove', response_model=Optional[KnowledgeFilesResponse]) def remove_file_from_knowledge_by_id( id: str, form_data: KnowledgeFileIdForm, @@ -807,12 +785,12 @@ def remove_file_from_knowledge_by_id( knowledge.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -833,32 +811,30 @@ def remove_file_from_knowledge_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - Knowledges.remove_file_from_knowledge_by_id( - knowledge_id=id, file_id=form_data.file_id, db=db - ) + Knowledges.remove_file_from_knowledge_by_id(knowledge_id=id, file_id=form_data.file_id, db=db) # Remove content from the vector database try: VECTOR_DB_CLIENT.delete( - collection_name=knowledge.id, filter={"file_id": form_data.file_id} + collection_name=knowledge.id, filter={'file_id': form_data.file_id} ) # Remove by file_id first VECTOR_DB_CLIENT.delete( - collection_name=knowledge.id, filter={"hash": file.hash} + collection_name=knowledge.id, filter={'hash': file.hash} ) # Remove by hash as well in case of duplicates except Exception as e: - log.debug("This was most likely caused by bypassing embedding processing") + log.debug('This was most likely caused by bypassing embedding processing') log.debug(e) pass if delete_file: try: # Remove the file's collection from vector database - file_collection = f"file-{form_data.file_id}" + file_collection = f'file-{form_data.file_id}' if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection): VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection) except Exception as e: - log.debug("This was most likely caused by bypassing embedding processing") + log.debug('This was most likely caused by bypassing embedding processing') log.debug(e) pass @@ -882,10 +858,8 @@ def remove_file_from_knowledge_by_id( ############################ -@router.delete("/{id}/delete", response_model=bool) -async def delete_knowledge_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.delete('/{id}/delete', response_model=bool) +async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( @@ -897,34 +871,34 @@ async def delete_knowledge_by_id( knowledge.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - log.info(f"Deleting knowledge base: {id} (name: {knowledge.name})") + log.info(f'Deleting knowledge base: {id} (name: {knowledge.name})') # Get all models models = Models.get_all_models(db=db) - log.info(f"Found {len(models)} models to check for knowledge base {id}") + log.info(f'Found {len(models)} models to check for knowledge base {id}') # Update models that reference this knowledge base for model in models: - if model.meta and hasattr(model.meta, "knowledge"): + if model.meta and hasattr(model.meta, 'knowledge'): knowledge_list = model.meta.knowledge or [] # Filter out the deleted knowledge base - updated_knowledge = [k for k in knowledge_list if k.get("id") != id] + updated_knowledge = [k for k in knowledge_list if k.get('id') != id] # If the knowledge list changed, update the model if len(updated_knowledge) != len(knowledge_list): - log.info(f"Updating model {model.id} to remove knowledge base {id}") + log.info(f'Updating model {model.id} to remove knowledge base {id}') model.meta.knowledge = updated_knowledge # Create a ModelForm for the update model_form = ModelForm( @@ -957,10 +931,8 @@ async def delete_knowledge_by_id( ############################ -@router.post("/{id}/reset", response_model=Optional[KnowledgeResponse]) -async def reset_knowledge_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.post('/{id}/reset', response_model=Optional[KnowledgeResponse]) +async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): knowledge = Knowledges.get_knowledge_by_id(id=id, db=db) if not knowledge: raise HTTPException( @@ -972,12 +944,12 @@ async def reset_knowledge_by_id( knowledge.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -999,7 +971,7 @@ async def reset_knowledge_by_id( ############################ -@router.post("/{id}/files/batch/add", response_model=Optional[KnowledgeFilesResponse]) +@router.post('/{id}/files/batch/add', response_model=Optional[KnowledgeFilesResponse]) async def add_files_to_knowledge_batch( request: Request, id: str, @@ -1021,12 +993,12 @@ async def add_files_to_knowledge_batch( knowledge.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -1034,7 +1006,7 @@ async def add_files_to_knowledge_batch( ) # Batch-fetch all files to avoid N+1 queries - log.info(f"files/batch/add - {len(form_data)} files") + log.info(f'files/batch/add - {len(form_data)} files') file_ids = [form.file_id for form in form_data] files = Files.get_files_by_ids(file_ids, db=db) @@ -1044,7 +1016,7 @@ async def add_files_to_knowledge_batch( if missing_ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"File {missing_ids[0]} not found", + detail=f'File {missing_ids[0]} not found', ) # Process files @@ -1056,27 +1028,23 @@ async def add_files_to_knowledge_batch( db=db, ) except Exception as e: - log.error( - f"add_files_to_knowledge_batch: Exception occurred: {e}", exc_info=True - ) + log.error(f'add_files_to_knowledge_batch: Exception occurred: {e}', exc_info=True) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) # Only add files that were successfully processed - successful_file_ids = [r.file_id for r in result.results if r.status == "completed"] + successful_file_ids = [r.file_id for r in result.results if r.status == 'completed'] for file_id in successful_file_ids: - Knowledges.add_file_to_knowledge_by_id( - knowledge_id=id, file_id=file_id, user_id=user.id, db=db - ) + Knowledges.add_file_to_knowledge_by_id(knowledge_id=id, file_id=file_id, user_id=user.id, db=db) # If there were any errors, include them in the response if result.errors: - error_details = [f"{err.file_id}: {err.error}" for err in result.errors] + error_details = [f'{err.file_id}: {err.error}' for err in result.errors] return KnowledgeFilesResponse( **knowledge.model_dump(), files=Knowledges.get_file_metadatas_by_id(knowledge.id, db=db), warnings={ - "message": "Some files failed to process", - "errors": error_details, + 'message': 'Some files failed to process', + 'errors': error_details, }, ) @@ -1091,10 +1059,8 @@ async def add_files_to_knowledge_batch( ############################ -@router.get("/{id}/export") -async def export_knowledge_by_id( - id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/{id}/export') +async def export_knowledge_by_id(id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): """ Export a knowledge base as a zip file containing .txt files. Admin only. @@ -1111,24 +1077,29 @@ async def export_knowledge_by_id( # Create zip file in memory zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: for file in files: - content = file.data.get("content", "") if file.data else "" + content = file.data.get('content', '') if file.data else '' if content: # Use original filename with .txt extension filename = file.filename - if not filename.endswith(".txt"): - filename = f"{filename}.txt" + if not filename.endswith('.txt'): + filename = f'{filename}.txt' zf.writestr(filename, content) zip_buffer.seek(0) # Sanitize knowledge name for filename - safe_name = "".join(c if c.isalnum() or c in " -_" else "_" for c in knowledge.name) - zip_filename = f"{safe_name}.zip" + # ASCII-safe fallback for the basic filename parameter (latin-1 safe) + safe_name = ''.join(c if c.isascii() and (c.isalnum() or c in ' -_') else '_' for c in knowledge.name) + zip_filename = f'{safe_name}.zip' + + # Use RFC 5987 filename* for non-ASCII names so the browser gets the real name + quoted_name = quote(f'{knowledge.name}.zip') + content_disposition = f'attachment; filename="{zip_filename}"; filename*=UTF-8\'\'{quoted_name}' return StreamingResponse( zip_buffer, - media_type="application/zip", - headers={"Content-Disposition": f"attachment; filename={zip_filename}"}, + media_type='application/zip', + headers={'Content-Disposition': content_disposition}, ) diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index db2bf5e238..4557f0c44d 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -20,10 +20,12 @@ ############################ # GetMemories +# Let what is remembered here spare someone the cost +# of learning it twice. ############################ -@router.get("/", response_model=list[MemoryModel]) +@router.get('/', response_model=list[MemoryModel]) async def get_memories( request: Request, user=Depends(get_verified_user), @@ -35,9 +37,7 @@ async def get_memories( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission( - user.id, "features.memories", request.app.state.config.USER_PERMISSIONS - ): + if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -59,7 +59,7 @@ class MemoryUpdateModel(BaseModel): content: Optional[str] = None -@router.post("/add", response_model=Optional[MemoryModel]) +@router.post('/add', response_model=Optional[MemoryModel]) async def add_memory( request: Request, form_data: AddMemoryForm, @@ -75,9 +75,7 @@ async def add_memory( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission( - user.id, "features.memories", request.app.state.config.USER_PERMISSIONS - ): + if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -88,13 +86,13 @@ async def add_memory( vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) VECTOR_DB_CLIENT.upsert( - collection_name=f"user-memory-{user.id}", + collection_name=f'user-memory-{user.id}', items=[ { - "id": memory.id, - "text": memory.content, - "vector": vector, - "metadata": {"created_at": memory.created_at}, + 'id': memory.id, + 'text': memory.content, + 'vector': vector, + 'metadata': {'created_at': memory.created_at}, } ], ) @@ -112,7 +110,7 @@ class QueryMemoryForm(BaseModel): k: Optional[int] = 1 -@router.post("/query") +@router.post('/query') async def query_memory( request: Request, form_data: QueryMemoryForm, @@ -128,9 +126,7 @@ async def query_memory( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission( - user.id, "features.memories", request.app.state.config.USER_PERMISSIONS - ): + if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -138,12 +134,12 @@ async def query_memory( memories = Memories.get_memories_by_user_id(user.id) if not memories: - raise HTTPException(status_code=404, detail="No memories found for user") + raise HTTPException(status_code=404, detail='No memories found for user') vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user) results = VECTOR_DB_CLIENT.search( - collection_name=f"user-memory-{user.id}", + collection_name=f'user-memory-{user.id}', vectors=[vector], limit=form_data.k, ) @@ -154,7 +150,7 @@ async def query_memory( ############################ # ResetMemoryFromVectorDB ############################ -@router.post("/reset", response_model=bool) +@router.post('/reset', response_model=bool) async def reset_memory_from_vector_db( request: Request, user=Depends(get_verified_user), @@ -173,36 +169,31 @@ async def reset_memory_from_vector_db( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission( - user.id, "features.memories", request.app.state.config.USER_PERMISSIONS - ): + if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}") + VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') memories = Memories.get_memories_by_user_id(user.id) # Generate vectors in parallel vectors = await asyncio.gather( - *[ - request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) - for memory in memories - ] + *[request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) for memory in memories] ) VECTOR_DB_CLIENT.upsert( - collection_name=f"user-memory-{user.id}", + collection_name=f'user-memory-{user.id}', items=[ { - "id": memory.id, - "text": memory.content, - "vector": vectors[idx], - "metadata": { - "created_at": memory.created_at, - "updated_at": memory.updated_at, + 'id': memory.id, + 'text': memory.content, + 'vector': vectors[idx], + 'metadata': { + 'created_at': memory.created_at, + 'updated_at': memory.updated_at, }, } for idx, memory in enumerate(memories) @@ -217,7 +208,7 @@ async def reset_memory_from_vector_db( ############################ -@router.delete("/delete/user", response_model=bool) +@router.delete('/delete/user', response_model=bool) async def delete_memory_by_user_id( request: Request, user=Depends(get_verified_user), @@ -229,9 +220,7 @@ async def delete_memory_by_user_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission( - user.id, "features.memories", request.app.state.config.USER_PERMISSIONS - ): + if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -241,7 +230,7 @@ async def delete_memory_by_user_id( if result: try: - VECTOR_DB_CLIENT.delete_collection(f"user-memory-{user.id}") + VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}') except Exception as e: log.error(e) return True @@ -254,7 +243,7 @@ async def delete_memory_by_user_id( ############################ -@router.post("/{memory_id}/update", response_model=Optional[MemoryModel]) +@router.post('/{memory_id}/update', response_model=Optional[MemoryModel]) async def update_memory_by_id( memory_id: str, request: Request, @@ -271,33 +260,29 @@ async def update_memory_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission( - user.id, "features.memories", request.app.state.config.USER_PERMISSIONS - ): + if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - memory = Memories.update_memory_by_id_and_user_id( - memory_id, user.id, form_data.content - ) + memory = Memories.update_memory_by_id_and_user_id(memory_id, user.id, form_data.content) if memory is None: - raise HTTPException(status_code=404, detail="Memory not found") + raise HTTPException(status_code=404, detail='Memory not found') if form_data.content is not None: vector = await request.app.state.EMBEDDING_FUNCTION(memory.content, user=user) VECTOR_DB_CLIENT.upsert( - collection_name=f"user-memory-{user.id}", + collection_name=f'user-memory-{user.id}', items=[ { - "id": memory.id, - "text": memory.content, - "vector": vector, - "metadata": { - "created_at": memory.created_at, - "updated_at": memory.updated_at, + 'id': memory.id, + 'text': memory.content, + 'vector': vector, + 'metadata': { + 'created_at': memory.created_at, + 'updated_at': memory.updated_at, }, } ], @@ -311,7 +296,7 @@ async def update_memory_by_id( ############################ -@router.delete("/{memory_id}", response_model=bool) +@router.delete('/{memory_id}', response_model=bool) async def delete_memory_by_id( memory_id: str, request: Request, @@ -324,9 +309,7 @@ async def delete_memory_by_id( detail=ERROR_MESSAGES.NOT_FOUND, ) - if not has_permission( - user.id, "features.memories", request.app.state.config.USER_PERMISSIONS - ): + if not has_permission(user.id, 'features.memories', request.app.state.config.USER_PERMISSIONS): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -335,9 +318,7 @@ async def delete_memory_by_id( result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id, db=db) if result: - VECTOR_DB_CLIENT.delete( - collection_name=f"user-memory-{user.id}", ids=[memory_id] - ) + VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) return True return False diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 95279bc378..5a56e11b68 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -49,15 +49,15 @@ def is_valid_model_id(model_id: str) -> bool: ########################### # GetModels +# Let each model here be judged by what it does and not +# by what it claims. The house deserves honest servants. ########################### 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) # do NOT use "/" as path, conflicts with main.py async def get_models( query: Optional[str] = None, view_option: Optional[str] = None, @@ -68,7 +68,6 @@ async def get_models( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - limit = PAGE_ITEM_COUNT page = max(1, page) @@ -76,25 +75,25 @@ async def get_models( filter = {} if query: - filter["query"] = query + filter['query'] = query if view_option: - filter["view_option"] = view_option + filter['view_option'] = view_option if tag: - filter["tag"] = tag + filter['tag'] = tag if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction # Pre-fetch user group IDs once - used for both filter and write_access check groups = Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} - if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: if groups: - filter["group_ids"] = [group.id for group in groups] + filter['group_ids'] = [group.id for group in groups] - filter["user_id"] = user.id + filter['user_id'] = user.id result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db) @@ -102,9 +101,9 @@ async def get_models( model_ids = [model.id for model in result.items] writable_model_ids = AccessGrants.get_accessible_resource_ids( user_id=user.id, - resource_type="model", + resource_type='model', resource_ids=model_ids, - permission="write", + permission='write', user_group_ids=user_group_ids, db=db, ) @@ -114,7 +113,7 @@ async def get_models( ModelAccessResponse( **model.model_dump(), write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == model.user_id or model.id in writable_model_ids ), @@ -130,10 +129,8 @@ async def get_models( ########################### -@router.get("/base", response_model=list[ModelResponse]) -async def get_base_models( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/base', response_model=list[ModelResponse]) +async def get_base_models(user=Depends(get_admin_user), db: Session = Depends(get_session)): return Models.get_base_models(db=db) @@ -142,11 +139,9 @@ async def get_base_models( ########################### -@router.get("/tags", response_model=list[str]) -async def get_model_tags( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: +@router.get('/tags', response_model=list[str]) +async def get_model_tags(user=Depends(get_verified_user), db: Session = Depends(get_session)): + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: models = Models.get_models(db=db) else: models = Models.get_models_by_user_id(user.id, db=db) @@ -155,11 +150,15 @@ async def get_model_tags( for model in models: if model.meta: meta = model.meta.model_dump() - for tag in meta.get("tags", []): - tags_set.add((tag.get("name"))) + for tag in meta.get('tags', []): + try: + name = tag.get('name') if isinstance(tag, dict) else str(tag) + if name: + tags_set.add(name) + except Exception: + continue - tags = [tag for tag in tags_set] - tags.sort() + tags = sorted(tags_set) return tags @@ -168,15 +167,15 @@ async def get_model_tags( ############################ -@router.post("/create", response_model=Optional[ModelModel]) +@router.post('/create', response_model=Optional[ModelModel]) async def create_new_model( request: Request, form_data: ModelForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "workspace.models", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'workspace.models', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -212,15 +211,15 @@ async def create_new_model( ############################ -@router.get("/export", response_model=list[ModelModel]) +@router.get('/export', response_model=list[ModelModel]) async def export_models( request: Request, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( + if user.role != 'admin' and not has_permission( user.id, - "workspace.models_export", + 'workspace.models_export', request.app.state.config.USER_PERMISSIONS, db=db, ): @@ -229,7 +228,7 @@ async def export_models( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: return Models.get_models(db=db) else: return Models.get_models_by_user_id(user.id, db=db) @@ -244,16 +243,16 @@ class ModelsImportForm(BaseModel): models: list[dict] -@router.post("/import", response_model=bool) +@router.post('/import', response_model=bool) async def import_models( request: Request, user=Depends(get_verified_user), form_data: ModelsImportForm = (...), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( + if user.role != 'admin' and not has_permission( user.id, - "workspace.models_import", + 'workspace.models_import', request.app.state.config.USER_PERMISSIONS, db=db, ): @@ -266,43 +265,36 @@ async def import_models( if isinstance(data, list): # Batch-fetch all existing models in one query to avoid N+1 model_ids = [ - model_data.get("id") + model_data.get('id') for model_data in data - if model_data.get("id") and is_valid_model_id(model_data.get("id")) + if model_data.get('id') and is_valid_model_id(model_data.get('id')) ] existing_models = { - model.id: model - for model in ( - Models.get_models_by_ids(model_ids, db=db) if model_ids else [] - ) + model.id: model for model in (Models.get_models_by_ids(model_ids, db=db) if model_ids else []) } for model_data in data: # Here, you can add logic to validate model_data if needed - model_id = model_data.get("id") + model_id = model_data.get('id') if model_id and is_valid_model_id(model_id): existing_model = existing_models.get(model_id) if existing_model: # Update existing model - model_data["meta"] = model_data.get("meta", {}) - model_data["params"] = model_data.get("params", {}) + model_data['meta'] = model_data.get('meta', {}) + model_data['params'] = model_data.get('params', {}) - updated_model = ModelForm( - **{**existing_model.model_dump(), **model_data} - ) + updated_model = ModelForm(**{**existing_model.model_dump(), **model_data}) Models.update_model_by_id(model_id, updated_model, db=db) else: # Insert new model - model_data["meta"] = model_data.get("meta", {}) - model_data["params"] = model_data.get("params", {}) + model_data['meta'] = model_data.get('meta', {}) + model_data['params'] = model_data.get('params', {}) new_model = ModelForm(**model_data) - Models.insert_new_model( - user_id=user.id, form_data=new_model, db=db - ) + Models.insert_new_model(user_id=user.id, form_data=new_model, db=db) return True else: - raise HTTPException(status_code=400, detail="Invalid JSON format") + raise HTTPException(status_code=400, detail='Invalid JSON format') except Exception as e: log.exception(e) raise HTTPException(status_code=500, detail=str(e)) @@ -317,7 +309,7 @@ class SyncModelsForm(BaseModel): models: list[ModelModel] = [] -@router.post("/sync", response_model=list[ModelModel]) +@router.post('/sync', response_model=list[ModelModel]) async def sync_models( request: Request, form_data: SyncModelsForm, @@ -337,33 +329,31 @@ class ModelIdForm(BaseModel): # Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id -@router.get("/model", response_model=Optional[ModelAccessResponse]) -async def get_model_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/model', response_model=Optional[ModelAccessResponse]) +async def get_model_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): model = Models.get_model_by_id(id, db=db) if model: if ( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or model.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model.id, - permission="read", + permission='read', db=db, ) ): return ModelAccessResponse( **model.model_dump(), write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == model.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model.id, - permission="write", + permission='write', db=db, ) ), @@ -385,7 +375,7 @@ async def get_model_by_id( ########################### -@router.get("/model/profile/image") +@router.get('/model/profile/image') def get_model_profile_image(id: str, user=Depends(get_verified_user)): model = Models.get_model_by_id(id) @@ -393,21 +383,21 @@ def get_model_profile_image(id: str, user=Depends(get_verified_user)): etag = f'"{model.updated_at}"' if model.updated_at else None if model.meta.profile_image_url: - if model.meta.profile_image_url.startswith("http"): + if model.meta.profile_image_url.startswith('http'): return Response( status_code=status.HTTP_302_FOUND, - headers={"Location": model.meta.profile_image_url}, + headers={'Location': model.meta.profile_image_url}, ) - elif model.meta.profile_image_url.startswith("data:image"): + elif model.meta.profile_image_url.startswith('data:image'): try: - header, base64_data = model.meta.profile_image_url.split(",", 1) + header, base64_data = model.meta.profile_image_url.split(',', 1) image_data = base64.b64decode(base64_data) image_buffer = io.BytesIO(image_data) - media_type = header.split(";")[0].lstrip("data:") + media_type = header.split(';')[0].lstrip('data:') - headers = {"Content-Disposition": "inline"} + headers = {'Content-Disposition': 'inline'} if etag: - headers["ETag"] = etag + headers['ETag'] = etag return StreamingResponse( image_buffer, @@ -417,9 +407,9 @@ def get_model_profile_image(id: str, user=Depends(get_verified_user)): except Exception as e: pass - return FileResponse(f"{STATIC_DIR}/favicon.png") + return FileResponse(f'{STATIC_DIR}/favicon.png') else: - return FileResponse(f"{STATIC_DIR}/favicon.png") + return FileResponse(f'{STATIC_DIR}/favicon.png') ############################ @@ -427,20 +417,18 @@ def get_model_profile_image(id: str, user=Depends(get_verified_user)): ############################ -@router.post("/model/toggle", response_model=Optional[ModelResponse]) -async def toggle_model_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.post('/model/toggle', response_model=Optional[ModelResponse]) +async def toggle_model_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): model = Models.get_model_by_id(id, db=db) if model: if ( - user.role == "admin" + user.role == 'admin' or model.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model.id, - permission="write", + permission='write', db=db, ) ): @@ -451,7 +439,7 @@ async def toggle_model_by_id( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating function"), + detail=ERROR_MESSAGES.DEFAULT('Error updating function'), ) else: raise HTTPException( @@ -470,7 +458,7 @@ async def toggle_model_by_id( ############################ -@router.post("/model/update", response_model=Optional[ModelModel]) +@router.post('/model/update', response_model=Optional[ModelModel]) async def update_model_by_id( form_data: ModelForm, user=Depends(get_verified_user), @@ -487,21 +475,19 @@ async def update_model_by_id( model.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - model = Models.update_model_by_id( - form_data.id, ModelForm(**form_data.model_dump()), db=db - ) + model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db) return model @@ -516,7 +502,7 @@ class ModelAccessGrantsForm(BaseModel): access_grants: list[dict] -@router.post("/model/access/update", response_model=Optional[ModelModel]) +@router.post('/model/access/update', response_model=Optional[ModelModel]) async def update_model_access_by_id( request: Request, form_data: ModelAccessGrantsForm, @@ -528,7 +514,7 @@ async def update_model_access_by_id( # Non-preset models (e.g. direct Ollama/OpenAI models) may not have a DB # entry yet. Create a minimal one so access grants can be stored. if not model: - if user.role != "admin": + if user.role != 'admin': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -546,19 +532,19 @@ async def update_model_access_by_id( if not model: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=ERROR_MESSAGES.DEFAULT("Error creating model entry"), + detail=ERROR_MESSAGES.DEFAULT('Error creating model entry'), ) if ( model.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -570,12 +556,10 @@ async def update_model_access_by_id( user.id, user.role, form_data.access_grants, - "sharing.public_models", + 'sharing.public_models', ) - AccessGrants.set_access_grants( - "model", form_data.id, form_data.access_grants, db=db - ) + AccessGrants.set_access_grants('model', form_data.id, form_data.access_grants, db=db) return Models.get_model_by_id(form_data.id, db=db) @@ -585,7 +569,7 @@ async def update_model_access_by_id( ############################ -@router.post("/model/delete", response_model=bool) +@router.post('/model/delete', response_model=bool) async def delete_model_by_id( form_data: ModelIdForm, user=Depends(get_verified_user), @@ -599,13 +583,13 @@ async def delete_model_by_id( ) if ( - user.role != "admin" + user.role != 'admin' and model.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model.id, - permission="write", + permission='write', db=db, ) ): @@ -618,9 +602,7 @@ async def delete_model_by_id( return result -@router.delete("/delete/all", response_model=bool) -async def delete_all_models( - user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.delete('/delete/all', response_model=bool) +async def delete_all_models(user=Depends(get_admin_user), db: Session = Depends(get_session)): result = Models.delete_all_models(db=db) return result diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index de8c18e934..705d86e1c9 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -27,7 +27,12 @@ from open_webui.utils.auth import get_admin_user, get_verified_user -from open_webui.utils.access_control import has_permission, filter_allowed_access_grants +from open_webui.utils.access_control import ( + has_permission, + has_public_read_access_grant, + has_public_write_access_grant, + filter_allowed_access_grants, +) from open_webui.models.access_grants import AccessGrants from open_webui.internal.db import get_session from sqlalchemy.orm import Session @@ -40,8 +45,8 @@ def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optional[dict]: if not data: return data - md = (data.get("content") or {}).get("md") or "" - return {"content": {"md": md[:max_length]}} + md = (data.get('content') or {}).get('md') or '' + return {'content': {'md': md[:max_length]}} ############################ @@ -58,15 +63,15 @@ class NoteItemResponse(BaseModel): user: Optional[UserResponse] = None -@router.get("/", response_model=list[NoteItemResponse]) +@router.get('/', response_model=list[NoteItemResponse]) async def get_notes( request: Request, page: Optional[int] = None, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -79,7 +84,7 @@ async def get_notes( limit = 60 skip = (page - 1) * limit - notes = Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit, db=db) + notes = Notes.get_notes_by_user_id(user.id, 'read', skip=skip, limit=limit, db=db) if not notes: return [] @@ -90,8 +95,8 @@ async def get_notes( NoteUserResponse( **{ **note.model_dump(), - "data": _truncate_note_data(note.data), - "user": UserResponse(**users[note.user_id].model_dump()), + 'data': _truncate_note_data(note.data), + 'user': UserResponse(**users[note.user_id].model_dump()), } ) for note in notes @@ -99,7 +104,7 @@ async def get_notes( ] -@router.get("/search", response_model=NoteListResponse) +@router.get('/search', response_model=NoteListResponse) async def search_notes( request: Request, query: Optional[str] = None, @@ -111,8 +116,8 @@ async def search_notes( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -127,22 +132,22 @@ async def search_notes( filter = {} if query: - filter["query"] = query + filter['query'] = query if view_option: - filter["view_option"] = view_option + filter['view_option'] = view_option if permission: - filter["permission"] = permission + filter['permission'] = permission if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction - if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL: + if not user.role == 'admin' or not BYPASS_ADMIN_ACCESS_CONTROL: groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: - filter["group_ids"] = [group.id for group in groups] + filter['group_ids'] = [group.id for group in groups] - filter["user_id"] = user.id + filter['user_id'] = user.id result = Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db) for note in result.items: @@ -155,15 +160,15 @@ async def search_notes( ############################ -@router.post("/create", response_model=Optional[NoteModel]) +@router.post('/create', response_model=Optional[NoteModel]) async def create_new_note( request: Request, form_data: NoteForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -175,9 +180,7 @@ async def create_new_note( return note except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -189,15 +192,15 @@ class NoteResponse(NoteModel): write_access: bool = False -@router.get("/{id}", response_model=Optional[NoteResponse]) +@router.get('/{id}', response_model=Optional[NoteResponse]) async def get_note_by_id( request: Request, id: str, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -206,37 +209,33 @@ async def get_note_by_id( note = 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 - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role != "admin" and ( + if user.role != 'admin' and ( user.id != note.user_id and ( not AccessGrants.has_access( user_id=user.id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="read", + permission='read', db=db, ) ) ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) write_access = ( - user.role == "admin" + user.role == 'admin' or (user.id == note.user_id) or AccessGrants.has_access( user_id=user.id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="write", + permission='write', db=db, ) - or has_public_read_access_grant(note.access_grants) + or has_public_write_access_grant(note.access_grants) ) return NoteResponse(**note.model_dump(), write_access=write_access) @@ -247,7 +246,7 @@ async def get_note_by_id( ############################ -@router.post("/{id}/update", response_model=Optional[NoteModel]) +@router.post('/{id}/update', response_model=Optional[NoteModel]) async def update_note_by_id( request: Request, id: str, @@ -255,8 +254,8 @@ async def update_note_by_id( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -265,47 +264,41 @@ async def update_note_by_id( note = 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 - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role != "admin" and ( + if user.role != 'admin' and ( user.id != note.user_id and not AccessGrants.has_access( user_id=user.id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="write", + permission='write', db=db, ) ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) form_data.access_grants = filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, form_data.access_grants, - "sharing.public_notes", + 'sharing.public_notes', db=db, ) try: note = Notes.update_note_by_id(id, form_data, db=db) await sio.emit( - "note-events", + 'note-events', note.model_dump(), - to=f"note:{note.id}", + to=f'note:{note.id}', ) return note except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) ############################ @@ -317,7 +310,7 @@ class NoteAccessGrantsForm(BaseModel): access_grants: list[dict] -@router.post("/{id}/access/update", response_model=Optional[NoteModel]) +@router.post('/{id}/access/update', response_model=Optional[NoteModel]) async def update_note_access_by_id( request: Request, id: str, @@ -325,8 +318,8 @@ async def update_note_access_by_id( user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -335,33 +328,29 @@ async def update_note_access_by_id( note = 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 - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role != "admin" and ( + if user.role != 'admin' and ( user.id != note.user_id and not AccessGrants.has_access( user_id=user.id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="write", + permission='write', db=db, ) ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) form_data.access_grants = filter_allowed_access_grants( request.app.state.config.USER_PERMISSIONS, user.id, user.role, form_data.access_grants, - "sharing.public_notes", + 'sharing.public_notes', ) - AccessGrants.set_access_grants("note", id, form_data.access_grants, db=db) + AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db) return Notes.get_note_by_id(id, db=db) @@ -371,15 +360,15 @@ async def update_note_access_by_id( ############################ -@router.delete("/{id}/delete", response_model=bool) +@router.delete('/{id}/delete', response_model=bool) async def delete_note_by_id( request: Request, id: str, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "features.notes", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'features.notes', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -388,29 +377,23 @@ async def delete_note_by_id( note = 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 - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) - if user.role != "admin" and ( + if user.role != 'admin' and ( user.id != note.user_id and not AccessGrants.has_access( user_id=user.id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="write", + permission='write', db=db, ) ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) try: note = Notes.delete_note_by_id(id, db=db) return True except Exception as e: log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT() - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index fe3f581d9c..08bf1681ed 100644 --- a/backend/open_webui/routers/ollama.py +++ b/backend/open_webui/routers/ollama.py @@ -79,6 +79,8 @@ ########################################## # # Utility functions +# Let what runs locally be trusted, and let no weight +# be loaded without serving the one who waits for the answer. # ########################################## @@ -88,8 +90,8 @@ async def send_get_request(url, key=None, user: UserModel = None): try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: headers = { - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: @@ -103,7 +105,7 @@ async def send_get_request(url, key=None, user: UserModel = None): return await response.json() except Exception as e: # Handle connection error here - log.error(f"Connection error: {e}") + log.error(f'Connection error: {e}') return None @@ -116,23 +118,20 @@ async def send_post_request( user: UserModel = None, metadata: Optional[dict] = None, ): - r = None streaming = False try: - session = aiohttp.ClientSession( - trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) - ) + session = aiohttp.ClientSession(trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT)) headers = { - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) - if metadata and metadata.get("chat_id"): - headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id") + if metadata and metadata.get('chat_id'): + headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') r = await session.post( url, @@ -145,15 +144,15 @@ async def send_post_request( try: res = await r.json() await cleanup_response(r, session) - if "error" in res: - raise HTTPException(status_code=r.status, detail=res["error"]) + if 'error' in res: + raise HTTPException(status_code=r.status, detail=res['error']) except HTTPException as e: raise e # Re-raise HTTPException to be handled by FastAPI except Exception as e: - log.error(f"Failed to parse error response: {e}") + log.error(f'Failed to parse error response: {e}') raise HTTPException( status_code=r.status, - detail=f"Open WebUI: Server Connection Error", + detail=f'Open WebUI: Server Connection Error', ) r.raise_for_status() # Raises an error for bad responses (4xx, 5xx) @@ -161,11 +160,11 @@ async def send_post_request( response_headers = dict(r.headers) if content_type: - response_headers["Content-Type"] = content_type + response_headers['Content-Type'] = content_type streaming = True return StreamingResponse( - stream_wrapper(user, payload["model"], payload, r, session), + stream_wrapper(user, payload['model'], payload, r, session), status_code=r.status, headers=response_headers, ) @@ -176,11 +175,11 @@ async def send_post_request( except HTTPException as e: raise e # Re-raise HTTPException to be handled by FastAPI except Exception as e: - detail = f"Ollama: {e}" + detail = f'Ollama: {e}' raise HTTPException( status_code=r.status if r else 500, - detail=detail if e else "Open WebUI: Server Connection Error", + detail=detail if e else 'Open WebUI: Server Connection Error', ) finally: if not streaming: @@ -189,10 +188,8 @@ async def send_post_request( def get_api_key(idx, url, configs): parsed_url = urlparse(url) - base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" - return configs.get(str(idx), configs.get(base_url, {})).get( - "key", None - ) # Legacy support + base_url = f'{parsed_url.scheme}://{parsed_url.netloc}' + return configs.get(str(idx), configs.get(base_url, {})).get('key', None) # Legacy support ########################################## @@ -204,10 +201,10 @@ def get_api_key(idx, url, configs): router = APIRouter() -@router.head("/") -@router.get("/") +@router.head('/') +@router.get('/') async def get_status(): - return {"status": True} + return {'status': True} class ConnectionVerificationForm(BaseModel): @@ -215,10 +212,8 @@ class ConnectionVerificationForm(BaseModel): key: Optional[str] = None -@router.post("/verify") -async def verify_connection( - form_data: ConnectionVerificationForm, user=Depends(get_admin_user) -): +@router.post('/verify') +async def verify_connection(form_data: ConnectionVerificationForm, user=Depends(get_admin_user)): url = form_data.url key = form_data.key @@ -228,44 +223,42 @@ async def verify_connection( ) as session: try: headers = { - **({"Authorization": f"Bearer {key}"} if key else {}), + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) async with session.get( - f"{url}/api/version", + f'{url}/api/version', headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: - detail = f"HTTP Error: {r.status}" + detail = f'HTTP Error: {r.status}' res = await r.json() - if "error" in res: - detail = f"External Error: {res['error']}" + if 'error' in res: + detail = f'External Error: {res["error"]}' raise Exception(detail) data = await r.json() return data except aiohttp.ClientError as e: - log.exception(f"Client error: {str(e)}") - raise HTTPException( - status_code=500, detail="Open WebUI: Server Connection Error" - ) + log.exception(f'Client error: {str(e)}') + raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') except Exception as e: - log.exception(f"Unexpected error: {e}") - error_detail = f"Unexpected error: {str(e)}" + log.exception(f'Unexpected error: {e}') + error_detail = f'Unexpected error: {str(e)}' raise HTTPException(status_code=500, detail=error_detail) -@router.get("/config") +@router.get('/config') async def get_config(request: Request, user=Depends(get_admin_user)): return { - "ENABLE_OLLAMA_API": request.app.state.config.ENABLE_OLLAMA_API, - "OLLAMA_BASE_URLS": request.app.state.config.OLLAMA_BASE_URLS, - "OLLAMA_API_CONFIGS": request.app.state.config.OLLAMA_API_CONFIGS, + 'ENABLE_OLLAMA_API': request.app.state.config.ENABLE_OLLAMA_API, + 'OLLAMA_BASE_URLS': request.app.state.config.OLLAMA_BASE_URLS, + 'OLLAMA_API_CONFIGS': request.app.state.config.OLLAMA_API_CONFIGS, } @@ -275,10 +268,8 @@ class OllamaConfigForm(BaseModel): OLLAMA_API_CONFIGS: dict -@router.post("/config/update") -async def update_config( - request: Request, form_data: OllamaConfigForm, user=Depends(get_admin_user) -): +@router.post('/config/update') +async def update_config(request: Request, form_data: OllamaConfigForm, user=Depends(get_admin_user)): request.app.state.config.ENABLE_OLLAMA_API = form_data.ENABLE_OLLAMA_API request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS @@ -287,15 +278,13 @@ async def update_config( # Remove the API configs that are not in the API URLS keys = list(map(str, range(len(request.app.state.config.OLLAMA_BASE_URLS)))) request.app.state.config.OLLAMA_API_CONFIGS = { - key: value - for key, value in request.app.state.config.OLLAMA_API_CONFIGS.items() - if key in keys + key: value for key, value in request.app.state.config.OLLAMA_API_CONFIGS.items() if key in keys } return { - "ENABLE_OLLAMA_API": request.app.state.config.ENABLE_OLLAMA_API, - "OLLAMA_BASE_URLS": request.app.state.config.OLLAMA_BASE_URLS, - "OLLAMA_API_CONFIGS": request.app.state.config.OLLAMA_API_CONFIGS, + 'ENABLE_OLLAMA_API': request.app.state.config.ENABLE_OLLAMA_API, + 'OLLAMA_BASE_URLS': request.app.state.config.OLLAMA_BASE_URLS, + 'OLLAMA_API_CONFIGS': request.app.state.config.OLLAMA_API_CONFIGS, } @@ -305,45 +294,41 @@ def merge_ollama_models_lists(model_lists): for idx, model_list in enumerate(model_lists): if model_list is not None: for model in model_list: - id = model.get("model") + id = model.get('model') if id is not None: if id not in merged_models: - model["urls"] = [idx] + model['urls'] = [idx] merged_models[id] = model else: - merged_models[id]["urls"].append(idx) + merged_models[id]['urls'].append(idx) return list(merged_models.values()) @cached( ttl=MODELS_CACHE_TTL, - key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models", + key=lambda _, user: f'ollama_all_models_{user.id}' if user else 'ollama_all_models', ) async def get_all_models(request: Request, user: UserModel = None): - log.info("get_all_models()") + log.info('get_all_models()') if request.app.state.config.ENABLE_OLLAMA_API: request_tasks = [] for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and ( url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support ): - request_tasks.append(send_get_request(f"{url}/api/tags", user=user)) + request_tasks.append(send_get_request(f'{url}/api/tags', user=user)) else: api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get( - url, {} - ), # Legacy support + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - enable = api_config.get("enable", True) - key = api_config.get("key", None) + enable = api_config.get('enable', True) + key = api_config.get('key', None) if enable: - request_tasks.append( - send_get_request(f"{url}/api/tags", key, user=user) - ) + request_tasks.append(send_get_request(f'{url}/api/tags', key, user=user)) else: request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) @@ -354,39 +339,37 @@ async def get_all_models(request: Request, user: UserModel = None): url = request.app.state.config.OLLAMA_BASE_URLS[idx] api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get( - url, {} - ), # Legacy support + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - connection_type = api_config.get("connection_type", "local") + connection_type = api_config.get('connection_type', 'local') - prefix_id = api_config.get("prefix_id", None) - tags = api_config.get("tags", []) - model_ids = api_config.get("model_ids", []) + prefix_id = api_config.get('prefix_id', None) + tags = api_config.get('tags', []) + model_ids = api_config.get('model_ids', []) - if len(model_ids) != 0 and "models" in response: - response["models"] = list( + if len(model_ids) != 0 and 'models' in response: + response['models'] = list( filter( - lambda model: model["model"] in model_ids, - response["models"], + lambda model: model['model'] in model_ids, + response['models'], ) ) - for model in response.get("models", []): + for model in response.get('models', []): if prefix_id: - model["model"] = f"{prefix_id}.{model['model']}" + model['model'] = f'{prefix_id}.{model["model"]}' if tags: - model["tags"] = tags + model['tags'] = tags if connection_type: - model["connection_type"] = connection_type + model['connection_type'] = connection_type models = { - "models": merge_ollama_models_lists( + 'models': merge_ollama_models_lists( map( - lambda response: response.get("models", []) if response else None, + lambda response: response.get('models', []) if response else None, responses, ) ) @@ -394,66 +377,53 @@ async def get_all_models(request: Request, user: UserModel = None): try: loaded_models = await get_ollama_loaded_models(request, user=user) - expires_map = { - m["model"]: m["expires_at"] - for m in loaded_models["models"] - if "expires_at" in m - } + expires_map = {m['model']: m['expires_at'] for m in loaded_models['models'] if 'expires_at' in m} - for m in models["models"]: - if m["model"] in expires_map: + for m in models['models']: + if m['model'] in expires_map: # Parse ISO8601 datetime with offset, get unix timestamp as int - dt = datetime.fromisoformat(expires_map[m["model"]]) - m["expires_at"] = int(dt.timestamp()) + dt = datetime.fromisoformat(expires_map[m['model']]) + m['expires_at'] = int(dt.timestamp()) except Exception as e: - log.debug(f"Failed to get loaded models: {e}") + log.debug(f'Failed to get loaded models: {e}') else: - models = {"models": []} + models = {'models': []} - request.app.state.OLLAMA_MODELS = { - model["model"]: model for model in models["models"] - } + request.app.state.OLLAMA_MODELS = {model['model']: model for model in models['models']} return models async def get_filtered_models(models, user, db=None): # Filter models based on user access control - model_ids = [model["model"] for model in models.get("models", [])] - model_infos = { - model_info.id: model_info - for model_info in Models.get_models_by_ids(model_ids, db=db) - } - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } + model_ids = [model['model'] for model in models.get('models', [])] + model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} # Batch-fetch accessible resource IDs in a single query instead of N has_access calls accessible_model_ids = AccessGrants.get_accessible_resource_ids( user_id=user.id, - resource_type="model", + resource_type='model', resource_ids=list(model_infos.keys()), - permission="read", + permission='read', user_group_ids=user_group_ids, db=db, ) filtered_models = [] - for model in models.get("models", []): - model_info = model_infos.get(model["model"]) + for model in models.get('models', []): + model_info = model_infos.get(model['model']) if model_info: if user.id == model_info.user_id or model_info.id in accessible_model_ids: filtered_models.append(model) return filtered_models -@router.get("/api/tags") -@router.get("/api/tags/{url_idx}") -async def get_ollama_tags( - request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user) -): +@router.get('/api/tags') +@router.get('/api/tags/{url_idx}') +async def get_ollama_tags(request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') models = [] @@ -466,15 +436,15 @@ async def get_ollama_tags( r = None try: headers = { - **({"Authorization": f"Bearer {key}"} if key else {}), + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) r = requests.request( - method="GET", - url=f"{url}/api/tags", + method='GET', + url=f'{url}/api/tags', headers=headers, ) r.raise_for_status() @@ -487,23 +457,23 @@ async def get_ollama_tags( if r is not None: try: res = r.json() - if "error" in res: - detail = f"Ollama: {res['error']}" + if 'error' in res: + detail = f'Ollama: {res["error"]}' except Exception: - detail = f"Ollama: {e}" + detail = f'Ollama: {e}' raise HTTPException( status_code=r.status_code if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) - if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL: - models["models"] = await get_filtered_models(models, user) + if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: + models['models'] = await get_filtered_models(models, user) return models -@router.get("/api/ps") +@router.get('/api/ps') async def get_ollama_loaded_models(request: Request, user=Depends(get_admin_user)): """ List models that are currently loaded into Ollama memory, and which node they are loaded on. @@ -514,22 +484,18 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_admin_user if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) and ( url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support ): - request_tasks.append(send_get_request(f"{url}/api/ps", user=user)) + request_tasks.append(send_get_request(f'{url}/api/ps', user=user)) else: api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get( - url, {} - ), # Legacy support + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - enable = api_config.get("enable", True) - key = api_config.get("key", None) + enable = api_config.get('enable', True) + key = api_config.get('key', None) if enable: - request_tasks.append( - send_get_request(f"{url}/api/ps", key, user=user) - ) + request_tasks.append(send_get_request(f'{url}/api/ps', key, user=user)) else: request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) @@ -540,33 +506,31 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_admin_user url = request.app.state.config.OLLAMA_BASE_URLS[idx] api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get( - url, {} - ), # Legacy support + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - prefix_id = api_config.get("prefix_id", None) + prefix_id = api_config.get('prefix_id', None) - for model in response.get("models", []): + for model in response.get('models', []): if prefix_id: - model["model"] = f"{prefix_id}.{model['model']}" + model['model'] = f'{prefix_id}.{model["model"]}' models = { - "models": merge_ollama_models_lists( + 'models': merge_ollama_models_lists( map( - lambda response: response.get("models", []) if response else None, + lambda response: response.get('models', []) if response else None, responses, ) ) } else: - models = {"models": []} + models = {'models': []} return models -@router.get("/api/version") -@router.get("/api/version/{url_idx}") +@router.get('/api/version') +@router.get('/api/version/{url_idx}') async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): if request.app.state.config.ENABLE_OLLAMA_API: if url_idx is None: @@ -576,18 +540,16 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS): api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(idx), - request.app.state.config.OLLAMA_API_CONFIGS.get( - url, {} - ), # Legacy support + request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - enable = api_config.get("enable", True) - key = api_config.get("key", None) + enable = api_config.get('enable', True) + key = api_config.get('key', None) if enable: request_tasks.append( send_get_request( - f"{url}/api/version", + f'{url}/api/version', key, ) ) @@ -598,12 +560,10 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): if len(responses) > 0: lowest_version = min( responses, - key=lambda x: tuple( - map(int, re.sub(r"^v|-.*", "", x["version"]).split(".")) - ), + key=lambda x: tuple(map(int, re.sub(r'^v|-.*', '', x['version']).split('.'))), ) - return {"version": lowest_version["version"]} + return {'version': lowest_version['version']} else: raise HTTPException( status_code=500, @@ -614,7 +574,7 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): r = None try: - r = requests.request(method="GET", url=f"{url}/api/version") + r = requests.request(method='GET', url=f'{url}/api/version') r.raise_for_status() return r.json() @@ -625,49 +585,45 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None): if r is not None: try: res = r.json() - if "error" in res: - detail = f"Ollama: {res['error']}" + if 'error' in res: + detail = f'Ollama: {res["error"]}' except Exception: - detail = f"Ollama: {e}" + detail = f'Ollama: {e}' raise HTTPException( status_code=r.status_code if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) else: - return {"version": False} + return {'version': False} class ModelNameForm(BaseModel): model: Optional[str] = None model_config = ConfigDict( - extra="allow", + extra='allow', ) -@router.post("/api/unload") +@router.post('/api/unload') async def unload_model( request: Request, form_data: ModelNameForm, user=Depends(get_admin_user), ): form_data = form_data.model_dump(exclude_none=True) - model_name = form_data.get("model", form_data.get("name")) + model_name = form_data.get('model', form_data.get('name')) if not model_name: - raise HTTPException( - status_code=400, detail="Missing name of the model to unload." - ) + raise HTTPException(status_code=400, detail='Missing name of the model to unload.') # Refresh/load models if needed, get mapping from name to URLs await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if model_name not in models: - raise HTTPException( - status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name) - ) - url_indices = models[model_name]["urls"] + raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model_name)) + url_indices = models[model_name]['urls'] # Send unload to ALL url_indices results = [] @@ -679,36 +635,36 @@ async def unload_model( ) key = get_api_key(idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - prefix_id = api_config.get("prefix_id", None) - if prefix_id and model_name.startswith(f"{prefix_id}."): - model_name = model_name[len(f"{prefix_id}.") :] + prefix_id = api_config.get('prefix_id', None) + if prefix_id and model_name.startswith(f'{prefix_id}.'): + model_name = model_name[len(f'{prefix_id}.') :] - payload = {"model": model_name, "keep_alive": 0, "prompt": ""} + payload = {'model': model_name, 'keep_alive': 0, 'prompt': ''} try: res = await send_post_request( - url=f"{url}/api/generate", + url=f'{url}/api/generate', payload=json.dumps(payload), stream=False, key=key, user=user, ) - results.append({"url_idx": idx, "success": True, "response": res}) + results.append({'url_idx': idx, 'success': True, 'response': res}) except Exception as e: - log.exception(f"Failed to unload model on node {idx}: {e}") - errors.append({"url_idx": idx, "success": False, "error": str(e)}) + log.exception(f'Failed to unload model on node {idx}: {e}') + errors.append({'url_idx': idx, 'success': False, 'error': str(e)}) if len(errors) > 0: raise HTTPException( status_code=500, - detail=f"Failed to unload model on {len(errors)} nodes: {errors}", + detail=f'Failed to unload model on {len(errors)} nodes: {errors}', ) - return {"status": True} + return {'status': True} -@router.post("/api/pull") -@router.post("/api/pull/{url_idx}") +@router.post('/api/pull') +@router.post('/api/pull/{url_idx}') async def pull_model( request: Request, form_data: ModelNameForm, @@ -716,19 +672,19 @@ async def pull_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') form_data = form_data.model_dump(exclude_none=True) - form_data["model"] = form_data.get("model", form_data.get("name")) + form_data['model'] = form_data.get('model', form_data.get('name')) url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - log.info(f"url: {url}") + log.info(f'url: {url}') # Admin should be able to pull models from any source - payload = {**form_data, "insecure": True} + payload = {**form_data, 'insecure': True} return await send_post_request( - url=f"{url}/api/pull", + url=f'{url}/api/pull', payload=json.dumps(payload), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, @@ -741,8 +697,8 @@ class PushModelForm(BaseModel): stream: Optional[bool] = None -@router.delete("/api/push") -@router.delete("/api/push/{url_idx}") +@router.delete('/api/push') +@router.delete('/api/push/{url_idx}') async def push_model( request: Request, form_data: PushModelForm, @@ -750,14 +706,14 @@ async def push_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') if url_idx is None: await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.model in models: - url_idx = models[form_data.model]["urls"][0] + url_idx = models[form_data.model]['urls'][0] else: raise HTTPException( status_code=400, @@ -765,10 +721,10 @@ async def push_model( ) url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] - log.debug(f"url: {url}") + log.debug(f'url: {url}') return await send_post_request( - url=f"{url}/api/push", + url=f'{url}/api/push', payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, @@ -780,11 +736,11 @@ class CreateModelForm(BaseModel): stream: Optional[bool] = None path: Optional[str] = None - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') -@router.post("/api/create") -@router.post("/api/create/{url_idx}") +@router.post('/api/create') +@router.post('/api/create/{url_idx}') async def create_model( request: Request, form_data: CreateModelForm, @@ -792,13 +748,13 @@ async def create_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') - log.debug(f"form_data: {form_data}") + log.debug(f'form_data: {form_data}') url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] return await send_post_request( - url=f"{url}/api/create", + url=f'{url}/api/create', payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, @@ -810,8 +766,8 @@ class CopyModelForm(BaseModel): destination: str -@router.post("/api/copy") -@router.post("/api/copy/{url_idx}") +@router.post('/api/copy') +@router.post('/api/copy/{url_idx}') async def copy_model( request: Request, form_data: CopyModelForm, @@ -819,14 +775,14 @@ async def copy_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') if url_idx is None: await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if form_data.source in models: - url_idx = models[form_data.source]["urls"][0] + url_idx = models[form_data.source]['urls'][0] else: raise HTTPException( status_code=400, @@ -838,22 +794,22 @@ async def copy_model( try: headers = { - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) r = requests.request( - method="POST", - url=f"{url}/api/copy", + method='POST', + url=f'{url}/api/copy', headers=headers, data=form_data.model_dump_json(exclude_none=True).encode(), ) r.raise_for_status() - log.debug(f"r.text: {r.text}") + log.debug(f'r.text: {r.text}') return True except Exception as e: log.exception(e) @@ -862,19 +818,19 @@ async def copy_model( if r is not None: try: res = r.json() - if "error" in res: - detail = f"Ollama: {res['error']}" + if 'error' in res: + detail = f'Ollama: {res["error"]}' except Exception: - detail = f"Ollama: {e}" + detail = f'Ollama: {e}' raise HTTPException( status_code=r.status_code if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) -@router.delete("/api/delete") -@router.delete("/api/delete/{url_idx}") +@router.delete('/api/delete') +@router.delete('/api/delete/{url_idx}') async def delete_model( request: Request, form_data: ModelNameForm, @@ -882,19 +838,19 @@ async def delete_model( user=Depends(get_admin_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') form_data = form_data.model_dump(exclude_none=True) - form_data["model"] = form_data.get("model", form_data.get("name")) + form_data['model'] = form_data.get('model', form_data.get('name')) - model = form_data.get("model") + model = form_data.get('model') if url_idx is None: await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS if model in models: - url_idx = models[model]["urls"][0] + url_idx = models[model]['urls'][0] else: raise HTTPException( status_code=400, @@ -907,22 +863,22 @@ async def delete_model( r = None try: headers = { - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) r = requests.request( - method="DELETE", - url=f"{url}/api/delete", + method='DELETE', + url=f'{url}/api/delete', headers=headers, json=form_data, ) r.raise_for_status() - log.debug(f"r.text: {r.text}") + log.debug(f'r.text: {r.text}') return True except Exception as e: log.exception(e) @@ -931,31 +887,29 @@ async def delete_model( if r is not None: try: res = r.json() - if "error" in res: - detail = f"Ollama: {res['error']}" + if 'error' in res: + detail = f'Ollama: {res["error"]}' except Exception: - detail = f"Ollama: {e}" + detail = f'Ollama: {e}' raise HTTPException( status_code=r.status_code if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) -@router.post("/api/show") -async def show_model_info( - request: Request, form_data: ModelNameForm, user=Depends(get_verified_user) -): +@router.post('/api/show') +async def show_model_info(request: Request, form_data: ModelNameForm, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') form_data = form_data.model_dump(exclude_none=True) - form_data["model"] = form_data.get("model", form_data.get("name")) + form_data['model'] = form_data.get('model', form_data.get('name')) await get_all_models(request, user=user) models = request.app.state.OLLAMA_MODELS - model = form_data.get("model") + model = form_data.get('model') if model not in models: raise HTTPException( @@ -963,23 +917,21 @@ async def show_model_info( detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model), ) - url_idx = random.choice(models[model]["urls"]) + url_idx = random.choice(models[model]['urls']) url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) try: headers = { - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) - r = requests.request( - method="POST", url=f"{url}/api/show", headers=headers, json=form_data - ) + r = requests.request(method='POST', url=f'{url}/api/show', headers=headers, json=form_data) r.raise_for_status() return r.json() @@ -990,14 +942,14 @@ async def show_model_info( if r is not None: try: res = r.json() - if "error" in res: - detail = f"Ollama: {res['error']}" + if 'error' in res: + detail = f'Ollama: {res["error"]}' except Exception: - detail = f"Ollama: {e}" + detail = f'Ollama: {e}' raise HTTPException( status_code=r.status_code if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) @@ -1009,12 +961,12 @@ class GenerateEmbedForm(BaseModel): keep_alive: Optional[Union[int, str]] = None model_config = ConfigDict( - extra="allow", + extra='allow', ) -@router.post("/api/embed") -@router.post("/api/embed/{url_idx}") +@router.post('/api/embed') +@router.post('/api/embed/{url_idx}') async def embed( request: Request, form_data: GenerateEmbedForm, @@ -1022,9 +974,9 @@ async def embed( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') - log.info(f"generate_ollama_batch_embeddings {form_data}") + log.info(f'generate_ollama_batch_embeddings {form_data}') if url_idx is None: model = form_data.model @@ -1036,7 +988,7 @@ async def embed( models = request.app.state.OLLAMA_MODELS if model in models: - url_idx = random.choice(models[model]["urls"]) + url_idx = random.choice(models[model]['urls']) else: raise HTTPException( status_code=400, @@ -1050,22 +1002,22 @@ async def embed( ) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - prefix_id = api_config.get("prefix_id", None) + prefix_id = api_config.get('prefix_id', None) if prefix_id: - form_data.model = form_data.model.replace(f"{prefix_id}.", "") + form_data.model = form_data.model.replace(f'{prefix_id}.', '') try: headers = { - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) r = requests.request( - method="POST", - url=f"{url}/api/embed", + method='POST', + url=f'{url}/api/embed', headers=headers, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -1080,14 +1032,14 @@ async def embed( if r is not None: try: res = r.json() - if "error" in res: - detail = f"Ollama: {res['error']}" + if 'error' in res: + detail = f'Ollama: {res["error"]}' except Exception: - detail = f"Ollama: {e}" + detail = f'Ollama: {e}' raise HTTPException( status_code=r.status_code if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) @@ -1098,8 +1050,8 @@ class GenerateEmbeddingsForm(BaseModel): keep_alive: Optional[Union[int, str]] = None -@router.post("/api/embeddings") -@router.post("/api/embeddings/{url_idx}") +@router.post('/api/embeddings') +@router.post('/api/embeddings/{url_idx}') async def embeddings( request: Request, form_data: GenerateEmbeddingsForm, @@ -1107,9 +1059,9 @@ async def embeddings( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') - log.info(f"generate_ollama_embeddings {form_data}") + log.info(f'generate_ollama_embeddings {form_data}') # check credit if user: @@ -1125,7 +1077,7 @@ async def embeddings( models = request.app.state.OLLAMA_MODELS if model in models: - url_idx = random.choice(models[model]["urls"]) + url_idx = random.choice(models[model]['urls']) else: raise HTTPException( status_code=400, @@ -1139,22 +1091,22 @@ async def embeddings( ) key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS) - prefix_id = api_config.get("prefix_id", None) + prefix_id = api_config.get('prefix_id', None) if prefix_id: - form_data.model = form_data.model.replace(f"{prefix_id}.", "") + form_data.model = form_data.model.replace(f'{prefix_id}.', '') try: headers = { - "Content-Type": "application/json", - **({"Authorization": f"Bearer {key}"} if key else {}), + 'Content-Type': 'application/json', + **({'Authorization': f'Bearer {key}'} if key else {}), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) r = requests.request( - method="POST", - url=f"{url}/api/embeddings", + method='POST', + url=f'{url}/api/embeddings', headers=headers, data=form_data.model_dump_json(exclude_none=True).encode(), ) @@ -1168,7 +1120,7 @@ async def embeddings( with CreditDeduct( user=user, model_id=form_data.model, - body={"messages": [{"role": "user", "content": input_text}]}, + body={'messages': [{'role': 'user', 'content': input_text}]}, is_stream=False, is_embedding=True, ) as credit_deduct: @@ -1182,14 +1134,14 @@ async def embeddings( if r is not None: try: res = r.json() - if "error" in res: - detail = f"Ollama: {res['error']}" + if 'error' in res: + detail = f'Ollama: {res["error"]}' except Exception: - detail = f"Ollama: {e}" + detail = f'Ollama: {e}' raise HTTPException( status_code=r.status_code if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) @@ -1208,8 +1160,8 @@ class GenerateCompletionForm(BaseModel): keep_alive: Optional[Union[int, str]] = None -@router.post("/api/generate") -@router.post("/api/generate/{url_idx}") +@router.post('/api/generate') +@router.post('/api/generate/{url_idx}') async def generate_completion( request: Request, form_data: GenerateCompletionForm, @@ -1217,7 +1169,7 @@ async def generate_completion( user=Depends(get_verified_user), ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') if url_idx is None: await get_all_models(request, user=user) @@ -1225,7 +1177,7 @@ async def generate_completion( model = form_data.model if model in models: - url_idx = random.choice(models[model]["urls"]) + url_idx = random.choice(models[model]['urls']) else: raise HTTPException( status_code=400, @@ -1238,12 +1190,12 @@ async def generate_completion( request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - prefix_id = api_config.get("prefix_id", None) + prefix_id = api_config.get('prefix_id', None) if prefix_id: - form_data.model = form_data.model.replace(f"{prefix_id}.", "") + form_data.model = form_data.model.replace(f'{prefix_id}.', '') return await send_post_request( - url=f"{url}/api/generate", + url=f'{url}/api/generate', payload=form_data.model_dump_json(exclude_none=True).encode(), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, @@ -1256,16 +1208,12 @@ class ChatMessage(BaseModel): tool_calls: Optional[list[dict]] = None images: Optional[list[str]] = None - @validator("content", pre=True) + @validator('content', pre=True) @classmethod def check_at_least_one_field(cls, field_value, values, **kwargs): # Raise an error if both 'content' and 'tool_calls' are None - if field_value is None and ( - "tool_calls" not in values or values["tool_calls"] is None - ): - raise ValueError( - "At least one of 'content' or 'tool_calls' must be provided" - ) + if field_value is None and ('tool_calls' not in values or values['tool_calls'] is None): + raise ValueError("At least one of 'content' or 'tool_calls' must be provided") return field_value @@ -1280,7 +1228,7 @@ class GenerateChatCompletionForm(BaseModel): keep_alive: Optional[Union[int, str]] = None tools: Optional[list[dict]] = None model_config = ConfigDict( - extra="allow", + extra='allow', ) @@ -1292,32 +1240,36 @@ async def get_ollama_url(request: Request, model: str, url_idx: Optional[int] = status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(model), ) - url_idx = random.choice(models[model].get("urls", [])) + url_idx = random.choice(models[model].get('urls', [])) url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] return url, url_idx -@router.post("/api/chat") -@router.post("/api/chat/{url_idx}") +@router.post('/api/chat') +@router.post('/api/chat/{url_idx}') async def generate_chat_completion( request: Request, form_data: dict, url_idx: Optional[int] = None, user=Depends(get_verified_user), - bypass_filter: Optional[bool] = False, bypass_system_prompt: bool = False, ): if not request.app.state.config.ENABLE_OLLAMA_API: - raise HTTPException(status_code=503, detail="Ollama API is disabled") + raise HTTPException(status_code=503, detail='Ollama API is disabled') # NOTE: We intentionally do NOT use Depends(get_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), # which would exhaust the connection pool under concurrent load. + + # bypass_filter is read from request.state to prevent external clients from + # setting it via query parameter (CVE fix). Only internal server-side callers + # (e.g. utils/chat.py) should set request.state.bypass_filter = True. + bypass_filter = getattr(request.state, 'bypass_filter', False) if BYPASS_MODEL_ACCESS_CONTROL: bypass_filter = True - metadata = form_data.pop("metadata", None) + metadata = form_data.pop('metadata', None) try: form_data = GenerateChatCompletionForm(**form_data) except Exception as e: @@ -1330,72 +1282,68 @@ async def generate_chat_completion( if isinstance(form_data, BaseModel): payload = {**form_data.model_dump(exclude_none=True)} - if "metadata" in payload: - del payload["metadata"] + if 'metadata' in payload: + del payload['metadata'] - model_id = payload["model"] + model_id = payload['model'] model_info = Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: base_model_id = ( - request.base_model_id - if hasattr(request, "base_model_id") - else model_info.base_model_id + request.base_model_id if hasattr(request, 'base_model_id') else model_info.base_model_id ) # Use request's base_model_id if available - payload["model"] = base_model_id + payload['model'] = base_model_id params = model_info.params.model_dump() if params: - system = params.pop("system", None) + system = params.pop('system', None) payload = apply_model_params_to_body_ollama(params, payload) if not bypass_system_prompt: payload = apply_system_prompt_to_body(system, payload, metadata, user) # Check if user has access to the model - if not bypass_filter and user.role == "user": - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id) - } + if not bypass_filter and user.role == 'user': + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} if not ( user.id == model_info.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model_info.id, - permission="read", + permission='read', user_group_ids=user_group_ids, ) ): raise HTTPException( status_code=403, - detail="Model not found", + detail='Model not found', ) elif not bypass_filter: - if user.role != "admin": + if user.role != 'admin': raise HTTPException( status_code=403, - detail="Model not found", + detail='Model not found', ) - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) + url, url_idx = await get_ollama_url(request, payload['model'], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - prefix_id = api_config.get("prefix_id", None) + prefix_id = api_config.get('prefix_id', None) if prefix_id: - payload["model"] = payload["model"].replace(f"{prefix_id}.", "") + payload['model'] = payload['model'].replace(f'{prefix_id}.', '') return await send_post_request( - url=f"{url}/api/chat", + url=f'{url}/api/chat', payload=json.dumps(payload), stream=form_data.stream, key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), - content_type="application/x-ndjson", + content_type='application/x-ndjson', user=user, metadata=metadata, ) @@ -1404,32 +1352,32 @@ async def generate_chat_completion( # TODO: we should update this part once Ollama supports other types class OpenAIChatMessageContent(BaseModel): type: str - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class OpenAIChatMessage(BaseModel): role: str content: Union[Optional[str], list[OpenAIChatMessageContent]] - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class OpenAIChatCompletionForm(BaseModel): model: str messages: list[OpenAIChatMessage] - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') class OpenAICompletionForm(BaseModel): model: str prompt: str - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') -@router.post("/v1/completions") -@router.post("/v1/completions/{url_idx}") +@router.post('/v1/completions') +@router.post('/v1/completions/{url_idx}') async def generate_openai_completion( request: Request, form_data: dict, @@ -1440,7 +1388,7 @@ async def generate_openai_completion( # 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), # which would exhaust the connection pool under concurrent load. - metadata = form_data.pop("metadata", None) + metadata = form_data.pop('metadata', None) try: form_data = OpenAICompletionForm(**form_data) @@ -1451,69 +1399,67 @@ async def generate_openai_completion( detail=str(e), ) - payload = {**form_data.model_dump(exclude_none=True, exclude=["metadata"])} - if "metadata" in payload: - del payload["metadata"] + payload = {**form_data.model_dump(exclude_none=True, exclude=['metadata'])} + if 'metadata' in payload: + del payload['metadata'] model_id = form_data.model model_info = Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: - payload["model"] = model_info.base_model_id + payload['model'] = model_info.base_model_id params = model_info.params.model_dump() if params: payload = apply_model_params_to_body_openai(params, payload) # Check if user has access to the model - if user.role == "user": - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id) - } + if user.role == 'user': + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} if not ( user.id == model_info.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model_info.id, - permission="read", + permission='read', user_group_ids=user_group_ids, ) ): raise HTTPException( status_code=403, - detail="Model not found", + detail='Model not found', ) else: - if user.role != "admin": + if user.role != 'admin': raise HTTPException( status_code=403, - detail="Model not found", + detail='Model not found', ) - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) + url, url_idx = await get_ollama_url(request, payload['model'], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - prefix_id = api_config.get("prefix_id", None) + prefix_id = api_config.get('prefix_id', None) if prefix_id: - payload["model"] = payload["model"].replace(f"{prefix_id}.", "") + payload['model'] = payload['model'].replace(f'{prefix_id}.', '') return await send_post_request( - url=f"{url}/v1/completions", + url=f'{url}/v1/completions', payload=json.dumps(payload), - stream=payload.get("stream", False), + stream=payload.get('stream', False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, metadata=metadata, ) -@router.post("/v1/chat/completions") -@router.post("/v1/chat/completions/{url_idx}") +@router.post('/v1/chat/completions') +@router.post('/v1/chat/completions/{url_idx}') async def generate_openai_chat_completion( request: Request, form_data: dict, @@ -1527,7 +1473,7 @@ async def generate_openai_chat_completion( check_credit_by_user_id(user_id=user.id, form_data=form_data) - metadata = form_data.pop("metadata", None) + metadata = form_data.pop('metadata', None) try: completion_form = OpenAIChatCompletionForm(**form_data) @@ -1538,160 +1484,221 @@ async def generate_openai_chat_completion( detail=str(e), ) - payload = {**completion_form.model_dump(exclude_none=True, exclude=["metadata"])} - if "metadata" in payload: - del payload["metadata"] + payload = {**completion_form.model_dump(exclude_none=True, exclude=['metadata'])} + if 'metadata' in payload: + del payload['metadata'] model_id = completion_form.model model_info = Models.get_model_by_id(model_id) if model_info: if model_info.base_model_id: - payload["model"] = model_info.base_model_id + payload['model'] = model_info.base_model_id params = model_info.params.model_dump() if params: - system = params.pop("system", None) + system = params.pop('system', None) payload = apply_model_params_to_body_openai(params, payload) payload = apply_system_prompt_to_body(system, payload, metadata, user) # Check if user has access to the model - if user.role == "user": - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id) - } + if user.role == 'user': + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} if not ( user.id == model_info.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model_info.id, - permission="read", + permission='read', user_group_ids=user_group_ids, ) ): raise HTTPException( status_code=403, - detail="Model not found", + detail='Model not found', ) else: - if user.role != "admin": + if user.role != 'admin': raise HTTPException( status_code=403, - detail="Model not found", + detail='Model not found', ) - url, url_idx = await get_ollama_url(request, payload["model"], url_idx) + url, url_idx = await get_ollama_url(request, payload['model'], url_idx) api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( str(url_idx), request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support ) - prefix_id = api_config.get("prefix_id", None) + prefix_id = api_config.get('prefix_id', None) if prefix_id: - payload["model"] = payload["model"].replace(f"{prefix_id}.", "") + payload['model'] = payload['model'].replace(f'{prefix_id}.', '') return await send_post_request( - url=f"{url}/v1/chat/completions", + url=f'{url}/v1/chat/completions', payload=json.dumps(payload), - stream=payload.get("stream", False), + stream=payload.get('stream', False), key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), user=user, metadata=metadata, ) -@router.get("/v1/models") -@router.get("/v1/models/{url_idx}") +@router.post('/v1/messages') +@router.post('/v1/messages/{url_idx}') +async def generate_anthropic_messages( + request: Request, + form_data: dict, + url_idx: Optional[int] = None, + user=Depends(get_verified_user), +): + """ + Proxy for Ollama's Anthropic-compatible /v1/messages endpoint. + + Forwards the request as-is to the Ollama backend, applying the same + model resolution, access control, and prefix_id handling used by + the OpenAI-compatible /v1/chat/completions proxy. + + See https://docs.ollama.com/api/anthropic-compatibility + """ + if not request.app.state.config.ENABLE_OLLAMA_API: + raise HTTPException(status_code=503, detail='Ollama API is disabled') + + payload = {**form_data} + model_id = payload.get('model', '') + + model_info = Models.get_model_by_id(model_id) + if model_info: + if model_info.base_model_id: + payload['model'] = model_info.base_model_id + + # Check if user has access to the model + if user.role == 'user': + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} + if not ( + user.id == model_info.user_id + or AccessGrants.has_access( + user_id=user.id, + resource_type='model', + resource_id=model_info.id, + permission='read', + user_group_ids=user_group_ids, + ) + ): + raise HTTPException( + status_code=403, + detail='Model not found', + ) + else: + if user.role != 'admin': + raise HTTPException( + status_code=403, + detail='Model not found', + ) + + url, url_idx = await get_ollama_url(request, payload['model'], url_idx) + api_config = request.app.state.config.OLLAMA_API_CONFIGS.get( + str(url_idx), + request.app.state.config.OLLAMA_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}.', '') + + return await send_post_request( + url=f'{url}/v1/messages', + payload=json.dumps(payload), + stream=payload.get('stream', False), + content_type='text/event-stream' if payload.get('stream', False) else None, + key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS), + user=user, + ) + + +@router.get('/v1/models') +@router.get('/v1/models/{url_idx}') async def get_openai_models( request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - models = [] if url_idx is None: model_list = await get_all_models(request, user=user) models = [ { - "id": model["model"], - "object": "model", - "created": int(time.time()), - "owned_by": "openai", + 'id': model['model'], + 'object': 'model', + 'created': int(time.time()), + 'owned_by': 'openai', } - for model in model_list["models"] + for model in model_list['models'] ] else: url = request.app.state.config.OLLAMA_BASE_URLS[url_idx] try: - r = requests.request(method="GET", url=f"{url}/api/tags") + r = requests.request(method='GET', url=f'{url}/api/tags') r.raise_for_status() model_list = r.json() models = [ { - "id": model["model"], - "object": "model", - "created": int(time.time()), - "owned_by": "openai", + 'id': model['model'], + 'object': 'model', + 'created': int(time.time()), + 'owned_by': 'openai', } - for model in models["models"] + for model in models['models'] ] except Exception as e: log.exception(e) - error_detail = "Open WebUI: Server Connection Error" + error_detail = 'Open WebUI: Server Connection Error' if r is not None: try: res = r.json() - if "error" in res: - error_detail = f"Ollama: {res['error']}" + if 'error' in res: + error_detail = f'Ollama: {res["error"]}' except Exception: - error_detail = f"Ollama: {e}" + error_detail = f'Ollama: {e}' raise HTTPException( status_code=r.status_code if r else 500, detail=error_detail, ) - if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL: + if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: # Filter models based on user access control - model_ids = [model["id"] for model in models] - model_infos = { - model_info.id: model_info - for model_info in Models.get_models_by_ids(model_ids, db=db) - } - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } + model_ids = [model['id'] for model in models] + model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} # Batch-fetch accessible resource IDs in a single query instead of N has_access calls accessible_model_ids = AccessGrants.get_accessible_resource_ids( user_id=user.id, - resource_type="model", + resource_type='model', resource_ids=list(model_infos.keys()), - permission="read", + permission='read', user_group_ids=user_group_ids, db=db, ) filtered_models = [] for model in models: - model_info = model_infos.get(model["id"]) + model_info = model_infos.get(model['id']) if model_info: - if ( - user.id == model_info.user_id - or model_info.id in accessible_model_ids - ): + if user.id == model_info.user_id or model_info.id in accessible_model_ids: filtered_models.append(model) models = filtered_models return { - "data": models, - "object": "list", + 'data': models, + 'object': 'list', } @@ -1709,7 +1716,7 @@ def parse_huggingface_url(hf_url): parsed_url = urlparse(hf_url) # Get the path and split it into components - path_components = parsed_url.path.split("/") + path_components = parsed_url.path.split('/') # Extract the desired output model_file = path_components[-1] @@ -1719,9 +1726,7 @@ def parse_huggingface_url(hf_url): return None -async def download_file_stream( - ollama_url, file_url, file_path, file_name, chunk_size=1024 * 1024 -): +async def download_file_stream(ollama_url, file_url, file_path, file_name, chunk_size=1024 * 1024): done = False if os.path.exists(file_path): @@ -1729,17 +1734,15 @@ async def download_file_stream( else: current_size = 0 - headers = {"Range": f"bytes={current_size}-"} if current_size > 0 else {} + headers = {'Range': f'bytes={current_size}-'} if current_size > 0 else {} timeout = aiohttp.ClientTimeout(total=600) # Set the timeout async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: - async with session.get( - file_url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL - ) as response: - total_size = int(response.headers.get("content-length", 0)) + current_size + async with session.get(file_url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response: + total_size = int(response.headers.get('content-length', 0)) + current_size - with open(file_path, "ab+") as file: + with open(file_path, 'ab+') as file: async for data in response.content.iter_chunked(chunk_size): current_size += len(data) file.write(data) @@ -1753,42 +1756,40 @@ async def download_file_stream( file.close() hashed = calculate_sha256(file_path, chunk_size) - with open(file_path, "rb") as file: + with open(file_path, 'rb') as file: chunk_size = 1024 * 1024 * 2 - url = f"{ollama_url}/api/blobs/sha256:{hashed}" + url = f'{ollama_url}/api/blobs/sha256:{hashed}' with requests.Session() as session: response = session.post(url, data=file, timeout=30) if response.ok: res = { - "done": done, - "blob": f"sha256:{hashed}", - "name": file_name, + 'done': done, + 'blob': f'sha256:{hashed}', + 'name': file_name, } os.remove(file_path) - yield f"data: {json.dumps(res)}\n\n" + yield f'data: {json.dumps(res)}\n\n' else: - raise RuntimeError( - "Ollama: Could not create blob, Please try again." - ) + raise RuntimeError('Ollama: Could not create blob, Please try again.') # url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf" -@router.post("/models/download") -@router.post("/models/download/{url_idx}") +@router.post('/models/download') +@router.post('/models/download/{url_idx}') async def download_model( request: Request, form_data: UrlForm, url_idx: Optional[int] = None, user=Depends(get_admin_user), ): - allowed_hosts = ["https://huggingface.co/", "https://github.com/"] + allowed_hosts = ['https://huggingface.co/', 'https://github.com/'] if not any(form_data.url.startswith(host) for host in allowed_hosts): raise HTTPException( status_code=400, - detail="Invalid file_url. Only URLs from allowed hosts are permitted.", + detail='Invalid file_url. Only URLs from allowed hosts are permitted.', ) if url_idx is None: @@ -1798,7 +1799,7 @@ async def download_model( file_name = parse_huggingface_url(form_data.url) if file_name: - file_path = f"{UPLOAD_DIR}/{file_name}" + file_path = f'{UPLOAD_DIR}/{file_name}' return StreamingResponse( download_file_stream(url, form_data.url, file_path, file_name), @@ -1808,8 +1809,8 @@ async def download_model( # TODO: Progress bar does not reflect size & duration of upload. -@router.post("/models/upload") -@router.post("/models/upload/{url_idx}") +@router.post('/models/upload') +@router.post('/models/upload/{url_idx}') async def upload_model( request: Request, file: UploadFile = File(...), @@ -1826,7 +1827,7 @@ async def upload_model( # --- P1: save file locally --- chunk_size = 1024 * 1024 * 2 # 2 MB chunks - with open(file_path, "wb") as out_f: + with open(file_path, 'wb') as out_f: while True: chunk = file.file.read(chunk_size) # log.info(f"Chunk: {str(chunk)}") # DEBUG @@ -1837,72 +1838,70 @@ async def upload_model( async def file_process_stream(): nonlocal ollama_url total_size = os.path.getsize(file_path) - log.info(f"Total Model Size: {str(total_size)}") # DEBUG + log.info(f'Total Model Size: {str(total_size)}') # DEBUG # --- P2: SSE progress + calculate sha256 hash --- file_hash = calculate_sha256(file_path, chunk_size) - log.info(f"Model Hash: {str(file_hash)}") # DEBUG + log.info(f'Model Hash: {str(file_hash)}') # DEBUG try: - with open(file_path, "rb") as f: + with open(file_path, 'rb') as f: bytes_read = 0 while chunk := f.read(chunk_size): bytes_read += len(chunk) progress = round(bytes_read / total_size * 100, 2) data_msg = { - "progress": progress, - "total": total_size, - "completed": bytes_read, + 'progress': progress, + 'total': total_size, + 'completed': bytes_read, } - yield f"data: {json.dumps(data_msg)}\n\n" + yield f'data: {json.dumps(data_msg)}\n\n' # --- P3: Upload to ollama /api/blobs --- - with open(file_path, "rb") as f: - url = f"{ollama_url}/api/blobs/sha256:{file_hash}" + with open(file_path, 'rb') as f: + url = f'{ollama_url}/api/blobs/sha256:{file_hash}' response = requests.post(url, data=f) if response.ok: - log.info(f"Uploaded to /api/blobs") # DEBUG + log.info(f'Uploaded to /api/blobs') # DEBUG # Remove local file os.remove(file_path) # Create model in ollama model_name, ext = os.path.splitext(filename) - log.info(f"Created Model: {model_name}") # DEBUG + log.info(f'Created Model: {model_name}') # DEBUG create_payload = { - "model": model_name, + 'model': model_name, # Reference the file by its original name => the uploaded blob's digest - "files": {filename: f"sha256:{file_hash}"}, + 'files': {filename: f'sha256:{file_hash}'}, } - log.info(f"Model Payload: {create_payload}") # DEBUG + log.info(f'Model Payload: {create_payload}') # DEBUG # Call ollama /api/create # https://github.com/ollama/ollama/blob/main/docs/api.md#create-a-model create_resp = requests.post( - url=f"{ollama_url}/api/create", - headers={"Content-Type": "application/json"}, + url=f'{ollama_url}/api/create', + headers={'Content-Type': 'application/json'}, data=json.dumps(create_payload), ) if create_resp.ok: - log.info(f"API SUCCESS!") # DEBUG + log.info(f'API SUCCESS!') # DEBUG done_msg = { - "done": True, - "blob": f"sha256:{file_hash}", - "name": filename, - "model_created": model_name, + 'done': True, + 'blob': f'sha256:{file_hash}', + 'name': filename, + 'model_created': model_name, } - yield f"data: {json.dumps(done_msg)}\n\n" + yield f'data: {json.dumps(done_msg)}\n\n' else: - raise Exception( - f"Failed to create model in Ollama. {create_resp.text}" - ) + raise Exception(f'Failed to create model in Ollama. {create_resp.text}') else: - raise Exception("Ollama: Could not create blob, Please try again.") + raise Exception('Ollama: Could not create blob, Please try again.') except Exception as e: - res = {"error": str(e)} - yield f"data: {json.dumps(res)}\n\n" + res = {'error': str(e)} + yield f'data: {json.dumps(res)}\n\n' - return StreamingResponse(file_process_stream(), media_type="text/event-stream") + return StreamingResponse(file_process_stream(), media_type='text/event-stream') diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index f2500aa431..a723838aec 100644 --- a/backend/open_webui/routers/openai.py +++ b/backend/open_webui/routers/openai.py @@ -2,6 +2,7 @@ import hashlib import json import logging +import re from typing import Optional from urllib.parse import urlparse @@ -67,56 +68,75 @@ ########################################## # # Utility functions +# Let the responses returned through this gate be worth +# the question that summoned them. # ########################################## -async def send_get_request(url, key=None, user: UserModel = None): +async def send_get_request( + request: Request = None, + url=None, + key=None, + user: UserModel = None, + config=None, +): timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST) try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: - headers = { - **({"Authorization": f"Bearer {key}"} if key else {}), - } + if request and config: + headers, cookies = await get_headers_and_cookies(request, url, key, config, user=user) + else: + headers = { + **({'Authorization': f'Bearer {key}'} if key else {}), + } + cookies = None - if ENABLE_FORWARD_USER_INFO_HEADERS and user: - headers = include_user_info_headers(headers, user) + if ENABLE_FORWARD_USER_INFO_HEADERS and user: + headers = include_user_info_headers(headers, user) async with session.get( url, headers=headers, + cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: return await response.json() except Exception as e: # Handle connection error here - log.error(f"Connection error: {e}") + log.error(f'Connection error: {e}') return None -async def get_models_request(url, key=None, user: UserModel = None): +async def get_models_request( + request: Request = None, + url=None, + key=None, + user: UserModel = None, + config=None, +): if is_anthropic_url(url): return await get_anthropic_models(url, key, user=user) - return await send_get_request(f"{url}/models", key, user=user) + return await send_get_request(request, f'{url}/models', key, user=user, config=config) def openai_reasoning_model_handler(payload): """ Handle reasoning model specific parameters """ - if "max_tokens" in payload: + if 'max_tokens' in payload: # Convert "max_tokens" to "max_completion_tokens" for all reasoning models - payload["max_completion_tokens"] = payload["max_tokens"] - del payload["max_tokens"] + payload['max_completion_tokens'] = payload['max_tokens'] + del payload['max_tokens'] # Handle system role conversion based on model type - if payload["messages"][0]["role"] == "system": - model_lower = payload["model"].lower() + if payload['messages'][0]['role'] == 'system': + model_lower = payload['model'].lower() # Legacy models use "user" role instead of "system" - if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"): - payload["messages"][0]["role"] = "user" + if model_lower.startswith('o1-mini') or model_lower.startswith('o1-preview'): + payload['messages'][0]['role'] = 'user' else: - payload["messages"][0]["role"] = "developer" + payload['messages'][0]['role'] = 'developer' return payload @@ -131,57 +151,57 @@ async def get_headers_and_cookies( ): cookies = {} headers = { - "Content-Type": "application/json", + 'Content-Type': 'application/json', **( { - "HTTP-Referer": "https://openwebui.com/", - "X-Title": "Open WebUI", + 'HTTP-Referer': 'https://openwebui.com/', + 'X-Title': 'Open WebUI', } - if "openrouter.ai" in url + if 'openrouter.ai' in url else {} ), } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) - if metadata and metadata.get("chat_id"): - headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get("chat_id") + if metadata and metadata.get('chat_id'): + headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') token = None - auth_type = config.get("auth_type") + auth_type = config.get('auth_type') - if auth_type == "bearer" or auth_type is None: + if auth_type == 'bearer' or auth_type is None: # Default to bearer if not specified - token = f"{key}" - elif auth_type == "none": + token = f'{key}' + elif auth_type == 'none': token = None - elif auth_type == "session": + elif auth_type == 'session': cookies = request.cookies token = request.state.token.credentials - elif auth_type == "system_oauth": + elif auth_type == 'system_oauth': cookies = request.cookies oauth_token = None try: - if request.cookies.get("oauth_session_id", None): + if request.cookies.get('oauth_session_id', None): oauth_token = await request.app.state.oauth_manager.get_oauth_token( user.id, - request.cookies.get("oauth_session_id", None), + request.cookies.get('oauth_session_id', None), ) except Exception as e: - log.error(f"Error getting OAuth token: {e}") + log.error(f'Error getting OAuth token: {e}') if oauth_token: - token = f"{oauth_token.get('access_token', '')}" + token = f'{oauth_token.get("access_token", "")}' - elif auth_type in ("azure_ad", "microsoft_entra_id"): + elif auth_type in ('azure_ad', 'microsoft_entra_id'): token = get_microsoft_entra_id_access_token() if token: - headers["Authorization"] = f"Bearer {token}" + headers['Authorization'] = f'Bearer {token}' - if config.get("headers") and isinstance(config.get("headers"), dict): - headers = {**headers, **config.get("headers")} + if config.get('headers') and isinstance(config.get('headers'), dict): + headers = {**headers, **config.get('headers')} return headers, cookies @@ -193,11 +213,11 @@ def get_microsoft_entra_id_access_token(): """ try: token_provider = get_bearer_token_provider( - DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default" + DefaultAzureCredential(), 'https://cognitiveservices.azure.com/.default' ) return token_provider() except Exception as e: - log.error(f"Error getting Microsoft Entra ID access token: {e}") + log.error(f'Error getting Microsoft Entra ID access token: {e}') return None @@ -210,13 +230,13 @@ def get_microsoft_entra_id_access_token(): router = APIRouter() -@router.get("/config") +@router.get('/config') async def get_config(request: Request, user=Depends(get_admin_user)): return { - "ENABLE_OPENAI_API": request.app.state.config.ENABLE_OPENAI_API, - "OPENAI_API_BASE_URLS": request.app.state.config.OPENAI_API_BASE_URLS, - "OPENAI_API_KEYS": request.app.state.config.OPENAI_API_KEYS, - "OPENAI_API_CONFIGS": request.app.state.config.OPENAI_API_CONFIGS, + 'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API, + 'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS, + 'OPENAI_API_KEYS': request.app.state.config.OPENAI_API_KEYS, + 'OPENAI_API_CONFIGS': request.app.state.config.OPENAI_API_CONFIGS, } @@ -227,30 +247,21 @@ class OpenAIConfigForm(BaseModel): OPENAI_API_CONFIGS: dict -@router.post("/config/update") -async def update_config( - request: Request, form_data: OpenAIConfigForm, user=Depends(get_admin_user) -): +@router.post('/config/update') +async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depends(get_admin_user)): request.app.state.config.ENABLE_OPENAI_API = form_data.ENABLE_OPENAI_API request.app.state.config.OPENAI_API_BASE_URLS = form_data.OPENAI_API_BASE_URLS request.app.state.config.OPENAI_API_KEYS = form_data.OPENAI_API_KEYS # Check if API KEYS length is same than API URLS length - if len(request.app.state.config.OPENAI_API_KEYS) != len( - request.app.state.config.OPENAI_API_BASE_URLS - ): - if len(request.app.state.config.OPENAI_API_KEYS) > len( - request.app.state.config.OPENAI_API_BASE_URLS - ): - request.app.state.config.OPENAI_API_KEYS = ( - request.app.state.config.OPENAI_API_KEYS[ - : len(request.app.state.config.OPENAI_API_BASE_URLS) - ] - ) + if len(request.app.state.config.OPENAI_API_KEYS) != len(request.app.state.config.OPENAI_API_BASE_URLS): + if len(request.app.state.config.OPENAI_API_KEYS) > len(request.app.state.config.OPENAI_API_BASE_URLS): + request.app.state.config.OPENAI_API_KEYS = request.app.state.config.OPENAI_API_KEYS[ + : len(request.app.state.config.OPENAI_API_BASE_URLS) + ] else: - request.app.state.config.OPENAI_API_KEYS += [""] * ( - len(request.app.state.config.OPENAI_API_BASE_URLS) - - len(request.app.state.config.OPENAI_API_KEYS) + request.app.state.config.OPENAI_API_KEYS += [''] * ( + len(request.app.state.config.OPENAI_API_BASE_URLS) - len(request.app.state.config.OPENAI_API_KEYS) ) request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS @@ -258,34 +269,30 @@ async def update_config( # Remove the API configs that are not in the API URLS keys = list(map(str, range(len(request.app.state.config.OPENAI_API_BASE_URLS)))) request.app.state.config.OPENAI_API_CONFIGS = { - key: value - for key, value in request.app.state.config.OPENAI_API_CONFIGS.items() - if key in keys + key: value for key, value in request.app.state.config.OPENAI_API_CONFIGS.items() if key in keys } return { - "ENABLE_OPENAI_API": request.app.state.config.ENABLE_OPENAI_API, - "OPENAI_API_BASE_URLS": request.app.state.config.OPENAI_API_BASE_URLS, - "OPENAI_API_KEYS": request.app.state.config.OPENAI_API_KEYS, - "OPENAI_API_CONFIGS": request.app.state.config.OPENAI_API_CONFIGS, + 'ENABLE_OPENAI_API': request.app.state.config.ENABLE_OPENAI_API, + 'OPENAI_API_BASE_URLS': request.app.state.config.OPENAI_API_BASE_URLS, + 'OPENAI_API_KEYS': request.app.state.config.OPENAI_API_KEYS, + 'OPENAI_API_CONFIGS': request.app.state.config.OPENAI_API_CONFIGS, } -@router.post("/audio/speech") +@router.post('/audio/speech') async def speech(request: Request, user=Depends(get_verified_user)): idx = None try: - idx = request.app.state.config.OPENAI_API_BASE_URLS.index( - "https://api.openai.com/v1" - ) + idx = request.app.state.config.OPENAI_API_BASE_URLS.index('https://api.openai.com/v1') body = await request.body() name = hashlib.sha256(body).hexdigest() - SPEECH_CACHE_DIR = CACHE_DIR / "audio" / "speech" + SPEECH_CACHE_DIR = CACHE_DIR / 'audio' / 'speech' SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) - file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3") - file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json") + file_path = SPEECH_CACHE_DIR.joinpath(f'{name}.mp3') + file_body_path = SPEECH_CACHE_DIR.joinpath(f'{name}.json') # Check if the file already exists in the cache if file_path.is_file(): @@ -298,14 +305,12 @@ async def speech(request: Request, user=Depends(get_verified_user)): request.app.state.config.OPENAI_API_CONFIGS.get(url, {}), # Legacy support ) - headers, cookies = await get_headers_and_cookies( - request, url, key, api_config, user=user - ) + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) r = None try: r = requests.post( - url=f"{url}/audio/speech", + url=f'{url}/audio/speech', data=body, headers=headers, cookies=cookies, @@ -315,12 +320,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: + with open(file_path, 'wb') as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) - with open(file_body_path, "w") as f: - json.dump(json.loads(body.decode("utf-8")), f) + with open(file_body_path, 'w') as f: + json.dump(json.loads(body.decode('utf-8')), f) # Return the saved file return FileResponse(file_path) @@ -332,14 +337,14 @@ async def speech(request: Request, user=Depends(get_verified_user)): if r is not None: try: res = r.json() - if "error" in res: - detail = f"External: {res['error']}" + if 'error' in res: + detail = f'External: {res["error"]}' except Exception: - detail = f"External: {e}" + detail = f'External: {e}' raise HTTPException( status_code=r.status_code if r else 500, - detail=detail if detail else "Open WebUI: Server Connection Error", + detail=detail if detail else 'Open WebUI: Server Connection Error', ) except ValueError: @@ -368,45 +373,41 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: request.app.state.config.OPENAI_API_KEYS = api_keys # if there are more urls than keys, add empty keys else: - api_keys += [""] * (num_urls - num_keys) + api_keys += [''] * (num_urls - num_keys) request.app.state.config.OPENAI_API_KEYS = api_keys request_tasks = [] for idx, url in enumerate(api_base_urls): if (str(idx) not in api_configs) and (url not in api_configs): # Legacy support - request_tasks.append(get_models_request(url, api_keys[idx], user=user)) + request_tasks.append(get_models_request(request, url, api_keys[idx], user=user)) else: api_config = api_configs.get( str(idx), api_configs.get(url, {}), # Legacy support ) - enable = api_config.get("enable", True) - model_ids = api_config.get("model_ids", []) + enable = api_config.get('enable', True) + model_ids = api_config.get('model_ids', []) if enable: if len(model_ids) == 0: - request_tasks.append( - get_models_request(url, api_keys[idx], user=user) - ) + request_tasks.append(get_models_request(request, url, api_keys[idx], user=user, config=api_config)) else: model_list = { - "object": "list", - "data": [ + 'object': 'list', + 'data': [ { - "id": model_id, - "name": model_id, - "owned_by": "openai", - "openai": {"id": model_id}, - "urlIdx": idx, + 'id': model_id, + 'name': model_id, + 'owned_by': 'openai', + 'openai': {'id': model_id}, + 'urlIdx': idx, } for model_id in model_ids ], } - request_tasks.append( - asyncio.ensure_future(asyncio.sleep(0, model_list)) - ) + request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, model_list))) else: request_tasks.append(asyncio.ensure_future(asyncio.sleep(0, None))) @@ -420,61 +421,52 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list: api_configs.get(url, {}), # Legacy support ) - connection_type = api_config.get("connection_type", "external") - prefix_id = api_config.get("prefix_id", None) - tags = api_config.get("tags", []) + connection_type = api_config.get('connection_type', 'external') + prefix_id = api_config.get('prefix_id', None) + tags = api_config.get('tags', []) - model_list = ( - response if isinstance(response, list) else response.get("data", []) - ) + model_list = response if isinstance(response, list) else response.get('data', []) if not isinstance(model_list, list): # Catch non-list responses model_list = [] for model in model_list: # Remove name key if its value is None #16689 - if "name" in model and model["name"] is None: - del model["name"] + if 'name' in model and model['name'] is None: + del model['name'] if prefix_id: - model["id"] = ( - f"{prefix_id}.{model.get('id', model.get('name', ''))}" - ) + model['id'] = f'{prefix_id}.{model.get("id", model.get("name", ""))}' if tags: - model["tags"] = tags + model['tags'] = tags if connection_type: - model["connection_type"] = connection_type + model['connection_type'] = connection_type - log.debug(f"get_all_models:responses() {responses}") + log.debug(f'get_all_models:responses() {responses}') return responses async def get_filtered_models(models, user, db=None): # Filter models based on user access control - model_ids = [model["id"] for model in models.get("data", [])] - model_infos = { - model_info.id: model_info - for model_info in Models.get_models_by_ids(model_ids, db=db) - } - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } + model_ids = [model['id'] for model in models.get('data', [])] + model_infos = {model_info.id: model_info for model_info in Models.get_models_by_ids(model_ids, db=db)} + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} # Batch-fetch accessible resource IDs in a single query instead of N has_access calls accessible_model_ids = AccessGrants.get_accessible_resource_ids( user_id=user.id, - resource_type="model", + resource_type='model', resource_ids=list(model_infos.keys()), - permission="read", + permission='read', user_group_ids=user_group_ids, db=db, ) filtered_models = [] - for model in models.get("data", []): - model_info = model_infos.get(model["id"]) + for model in models.get('data', []): + model_info = model_infos.get(model['id']) if model_info: if user.id == model_info.user_id or model_info.id in accessible_model_ids: filtered_models.append(model) @@ -483,13 +475,13 @@ async def get_filtered_models(models, user, db=None): @cached( ttl=MODELS_CACHE_TTL, - key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models", + key=lambda _, user: f'openai_all_models_{user.id}' if user else 'openai_all_models', ) async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: - log.info("get_all_models()") + log.info('get_all_models()') if not request.app.state.config.ENABLE_OPENAI_API: - return {"data": []} + return {'data': []} # Cache config value locally to avoid repeated Redis lookups inside # the nested loop in get_merged_models (one GET per model otherwise). @@ -498,8 +490,8 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]: responses = await get_all_models_responses(request, user=user) def extract_data(response): - if response and "data" in response: - return response["data"] + if response and 'data' in response: + return response['data'] if isinstance(response, list): return response return None @@ -508,63 +500,59 @@ def is_supported_openai_models(model_id): if any( name in model_id for name in [ - "babbage", - "dall-e", - "davinci", - "embedding", - "tts", - "whisper", + 'babbage', + 'dall-e', + 'davinci', + 'embedding', + 'tts', + 'whisper', ] ): return False return True def get_merged_models(model_lists): - log.debug(f"merge_models_lists {model_lists}") + log.debug(f'merge_models_lists {model_lists}') models = {} for idx, model_list in enumerate(model_lists): - if model_list is not None and "error" not in model_list: + if model_list is not None and 'error' not in model_list: for model in model_list: - model_id = model.get("id") or model.get("name") + model_id = model.get('id') or model.get('name') base_url = api_base_urls[idx] hostname = urlparse(base_url).hostname if base_url else None - if hostname == "api.openai.com" and not is_supported_openai_models( - model_id - ): + if hostname == 'api.openai.com' and not is_supported_openai_models(model_id): # Skip unwanted OpenAI models continue if model_id and model_id not in models: models[model_id] = { **model, - "name": model.get("name", model_id), - "owned_by": "openai", - "openai": model, - "connection_type": model.get("connection_type", "external"), - "urlIdx": idx, + 'name': model.get('name', model_id), + 'owned_by': 'openai', + 'openai': model, + 'connection_type': model.get('connection_type', 'external'), + 'urlIdx': idx, } return models models = get_merged_models(map(extract_data, responses)) - log.debug(f"models: {models}") + log.debug(f'models: {models}') request.app.state.OPENAI_MODELS = models - return {"data": list(models.values())} + return {'data': list(models.values())} -@router.get("/models") -@router.get("/models/{url_idx}") -async def get_models( - request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user) -): +@router.get('/models') +@router.get('/models/{url_idx}') +async def get_models(request: Request, url_idx: Optional[int] = None, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_OPENAI_API: - raise HTTPException(status_code=503, detail="OpenAI API is disabled") + raise HTTPException(status_code=503, detail='OpenAI API is disabled') models = { - "data": [], + 'data': [], } if url_idx is None: @@ -584,51 +572,49 @@ async def get_models( timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: - headers, cookies = await get_headers_and_cookies( - request, url, key, api_config, user=user - ) + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) - if api_config.get("azure", False): + if api_config.get('azure', False): models = { - "data": api_config.get("model_ids", []) or [], - "object": "list", + 'data': api_config.get('model_ids', []) or [], + 'object': 'list', } elif is_anthropic_url(url): models = await get_anthropic_models(url, key, user=user) if models is None: - raise Exception("Failed to connect to Anthropic API") + raise Exception('Failed to connect to Anthropic API') else: async with session.get( - f"{url}/models", + f'{url}/models', headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status != 200: - error_detail = f"HTTP Error: {r.status}" + error_detail = f'HTTP Error: {r.status}' try: res = await r.json() - if "error" in res: - error_detail = f"External Error: {res['error']}" + if 'error' in res: + error_detail = f'External Error: {res["error"]}' except Exception: pass raise Exception(error_detail) response_data = await r.json() - if "api.openai.com" in url: - response_data["data"] = [ + if 'api.openai.com' in url: + response_data['data'] = [ model - for model in response_data.get("data", []) + for model in response_data.get('data', []) if not any( - name in model["id"] + name in model['id'] for name in [ - "babbage", - "dall-e", - "davinci", - "embedding", - "tts", - "whisper", + 'babbage', + 'dall-e', + 'davinci', + 'embedding', + 'tts', + 'whisper', ] ) ] @@ -636,17 +622,15 @@ async def get_models( models = response_data except aiohttp.ClientError as e: # ClientError covers all aiohttp requests issues - log.exception(f"Client error: {str(e)}") - raise HTTPException( - status_code=500, detail="Open WebUI: Server Connection Error" - ) + log.exception(f'Client error: {str(e)}') + raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') except Exception as e: - log.exception(f"Unexpected error: {e}") - error_detail = f"Unexpected error: {str(e)}" + log.exception(f'Unexpected error: {e}') + error_detail = f'Unexpected error: {str(e)}' raise HTTPException(status_code=500, detail=error_detail) - if user.role == "user" and not BYPASS_MODEL_ACCESS_CONTROL: - models["data"] = await get_filtered_models(models, user) + if user.role == 'user' and not BYPASS_MODEL_ACCESS_CONTROL: + models['data'] = await get_filtered_models(models, user) return models @@ -658,7 +642,7 @@ class ConnectionVerificationForm(BaseModel): config: Optional[dict] = None -@router.post("/verify") +@router.post('/verify') async def verify_connection( request: Request, form_data: ConnectionVerificationForm, @@ -674,19 +658,17 @@ async def verify_connection( timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST), ) as session: try: - headers, cookies = await get_headers_and_cookies( - request, url, key, api_config, user=user - ) + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) - if api_config.get("azure", False): + if api_config.get('azure', False): # Only set api-key header if not using Azure Entra ID authentication - auth_type = api_config.get("auth_type", "bearer") - if auth_type not in ("azure_ad", "microsoft_entra_id"): - headers["api-key"] = key + auth_type = api_config.get('auth_type', 'bearer') + if auth_type not in ('azure_ad', 'microsoft_entra_id'): + headers['api-key'] = key - api_version = api_config.get("api_version", "") or "2023-03-15-preview" + api_version = api_config.get('api_version', '') or '2023-03-15-preview' async with session.get( - url=f"{url}/openai/models?api-version={api_version}", + url=f'{url}/openai/models?api-version={api_version}', headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -698,27 +680,21 @@ async def verify_connection( if r.status != 200: if isinstance(response_data, (dict, list)): - return JSONResponse( - status_code=r.status, content=response_data - ) + return JSONResponse(status_code=r.status, content=response_data) else: - return PlainTextResponse( - status_code=r.status, content=response_data - ) + return PlainTextResponse(status_code=r.status, content=response_data) return response_data elif is_anthropic_url(url): result = await get_anthropic_models(url, key) if result is None: - raise HTTPException( - status_code=500, detail="Failed to connect to Anthropic API" - ) - if "error" in result: - raise HTTPException(status_code=500, detail=result["error"]) + raise HTTPException(status_code=500, detail='Failed to connect to Anthropic API') + if 'error' in result: + raise HTTPException(status_code=500, detail=result['error']) return result else: async with session.get( - f"{url}/models", + f'{url}/models', headers=headers, cookies=cookies, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -730,110 +706,140 @@ async def verify_connection( if r.status != 200: if isinstance(response_data, (dict, list)): - return JSONResponse( - status_code=r.status, content=response_data - ) + return JSONResponse(status_code=r.status, content=response_data) else: - return PlainTextResponse( - status_code=r.status, content=response_data - ) + return PlainTextResponse(status_code=r.status, content=response_data) return response_data except aiohttp.ClientError as e: # ClientError covers all aiohttp requests issues - log.exception(f"Client error: {str(e)}") - raise HTTPException( - status_code=500, detail="Open WebUI: Server Connection Error" - ) + log.exception(f'Client error: {str(e)}') + raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') except Exception as e: - log.exception(f"Unexpected error: {e}") - raise HTTPException( - status_code=500, detail="Open WebUI: Server Connection Error" - ) + log.exception(f'Unexpected error: {e}') + raise HTTPException(status_code=500, detail='Open WebUI: Server Connection Error') def get_azure_allowed_params(api_version: str) -> set[str]: allowed_params = { - "messages", - "temperature", - "role", - "content", - "contentPart", - "contentPartImage", - "enhancements", - "dataSources", - "n", - "stream", - "stop", - "max_tokens", - "presence_penalty", - "frequency_penalty", - "logit_bias", - "user", - "function_call", - "functions", - "tools", - "tool_choice", - "top_p", - "log_probs", - "top_logprobs", - "response_format", - "seed", - "max_completion_tokens", - "reasoning_effort", + 'messages', + 'temperature', + 'role', + 'content', + 'contentPart', + 'contentPartImage', + 'enhancements', + 'dataSources', + 'n', + 'stream', + 'stop', + 'max_tokens', + 'presence_penalty', + 'frequency_penalty', + 'logit_bias', + 'user', + 'function_call', + 'functions', + 'tools', + 'tool_choice', + 'top_p', + 'log_probs', + 'top_logprobs', + 'response_format', + 'seed', + 'max_completion_tokens', + 'reasoning_effort', } try: - if api_version >= "2024-09-01-preview": - allowed_params.add("stream_options") + if api_version >= '2024-09-01-preview': + allowed_params.add('stream_options') except ValueError: - log.debug( - f"Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters." - ) + log.debug(f'Invalid API version {api_version} for Azure OpenAI. Defaulting to allowed parameters.') return allowed_params -def is_openai_reasoning_model(model: str) -> bool: - # split / for openrouter.ai models - # split . for pipe models - real_model_name = (model.lower().split("/", 1)[-1]).split(".", 1)[-1] +o_series_regexp = re.compile(r'^o\d+') +gpt_series_regexp = re.compile(r'^gpt-(\d+)') - # check for reasoning models - reasoning_models_prefixes = ("o1", "o3", "o4", "gpt-5") - return real_model_name.startswith(reasoning_models_prefixes) or model.startswith( - reasoning_models_prefixes - ) + +def is_openai_new_model(model: str) -> bool: + try: + # split / for openrouter.ai models + # split . for pipe models + model_lower = model.lower() + real_model_name = (model_lower.split('/', 1)[-1]).split('.', 1)[-1] + + # o-series models (o1, o3, o4, o5, ...) + if o_series_regexp.match(model_lower) or o_series_regexp.match(real_model_name): + return True + + # gpt-N where N >= 5 (gpt-5, gpt-5.2, gpt-6, ...) + m = gpt_series_regexp.match(model_lower) + if m and int(m.group(1)) >= 5: + return True + m = gpt_series_regexp.match(real_model_name) + if m and int(m.group(1)) >= 5: + return True + except Exception as err: + log.error('check openai new model failed: %s', err) + return False def convert_to_azure_payload(url, payload: dict, api_version: str): - model = payload.get("model", "") + model = payload.get('model', '') # Filter allowed parameters based on Azure OpenAI API allowed_params = get_azure_allowed_params(api_version) # Special handling for o-series models - if is_openai_reasoning_model(model): + if is_openai_new_model(model): # Convert max_tokens to max_completion_tokens for o-series models - if "max_tokens" in payload: - payload["max_completion_tokens"] = payload["max_tokens"] - del payload["max_tokens"] + if 'max_tokens' in payload: + payload['max_completion_tokens'] = payload['max_tokens'] + del payload['max_tokens'] # Remove temperature if not 1 for o-series models - if "temperature" in payload and payload["temperature"] != 1: + if 'temperature' in payload and payload['temperature'] != 1: log.debug( - f"Removing temperature parameter for o-series model {model} as only default value (1) is supported" + f'Removing temperature parameter for o-series model {model} as only default value (1) is supported' ) - del payload["temperature"] + del payload['temperature'] # Filter out unsupported parameters payload = {k: v for k, v in payload.items() if k in allowed_params} - url = f"{url}/openai/deployments/{model}" + url = f'{url}/openai/deployments/{model}' return url, payload +# Fields accepted by the Responses API for each input item type. +RESPONSES_ALLOWED_FIELDS: dict[str, set[str]] = { + 'message': {'type', 'role', 'content'}, + 'function_call': {'type', 'call_id', 'name', 'arguments', 'id'}, + 'function_call_output': {'type', 'call_id', 'output'}, +} + + +def _normalize_stored_item(item: dict) -> dict: + """Strip local-only fields from a stored output item before replaying it. + + Open WebUI stores extra bookkeeping fields (``id``, ``status``, + ``started_at``, ``ended_at``, ``duration``, ``_tag_type``, + ``attributes``, ``summary``, etc.) that the Responses API does + not accept. This helper returns a copy containing only the + fields the API understands. + """ + item_type = item.get('type', '') + allowed = RESPONSES_ALLOWED_FIELDS.get(item_type) + if allowed is None: + # Unknown type โ€” pass through as-is (e.g. reasoning, extension items). + return item + return {k: v for k, v in item.items() if k in allowed} + + def convert_to_responses_payload(payload: dict) -> dict: """ Convert Chat Completions payload to Responses API format. @@ -841,114 +847,180 @@ def convert_to_responses_payload(payload: dict) -> dict: Chat Completions: { messages: [{role, content}], ... } Responses API: { input: [{type: "message", role, content: [...]}], instructions: "system" } """ - messages = payload.pop("messages", []) + messages = payload.pop('messages', []) - system_content = "" + system_content = '' input_items = [] for msg in messages: - role = msg.get("role", "user") - content = msg.get("content", "") + role = msg.get('role', 'user') + content = msg.get('content', '') # Check for stored output items (from previous Responses API turn) - stored_output = msg.get("output") + stored_output = msg.get('output') if stored_output and isinstance(stored_output, list): - input_items.extend(stored_output) + input_items.extend(_normalize_stored_item(item) for item in stored_output) continue - if role == "system": + if role == 'system': if isinstance(content, str): system_content = content elif isinstance(content, list): - system_content = "\n".join( - p.get("text", "") for p in content if p.get("type") == "text" + system_content = '\n'.join(p.get('text', '') for p in content if p.get('type') == 'text') + continue + + # Handle assistant messages with tool_calls (from convert_output_to_messages) + if role == 'assistant' and msg.get('tool_calls'): + # Add text content as message if present + if content: + text = ( + content + if isinstance(content, str) + else '\n'.join(p.get('text', '') for p in content if p.get('type') == 'text') ) + if text.strip(): + input_items.append( + { + 'type': 'message', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': text}], + } + ) + # Convert each tool_call to a function_call input item + for tool_call in msg['tool_calls']: + func = tool_call.get('function', {}) + input_items.append( + { + 'type': 'function_call', + 'call_id': tool_call.get('id', ''), + 'name': func.get('name', ''), + 'arguments': func.get('arguments', '{}'), + } + ) + continue + + # Handle tool result messages + if role == 'tool': + input_items.append( + { + 'type': 'function_call_output', + 'call_id': msg.get('tool_call_id', ''), + 'output': msg.get('content', ''), + } + ) continue # Convert content format - text_type = "output_text" if role == "assistant" else "input_text" + text_type = 'output_text' if role == 'assistant' else 'input_text' if isinstance(content, str): - content_parts = [{"type": text_type, "text": content}] + content_parts = [{'type': text_type, 'text': content}] elif isinstance(content, list): content_parts = [] for part in content: - if part.get("type") == "text": - content_parts.append( - {"type": text_type, "text": part.get("text", "")} - ) - elif part.get("type") == "image_url": - url_data = part.get("image_url", {}) - url = ( - url_data.get("url", "") - if isinstance(url_data, dict) - else url_data - ) - content_parts.append({"type": "input_image", "image_url": url}) + if part.get('type') == 'text': + content_parts.append({'type': text_type, 'text': part.get('text', '')}) + elif part.get('type') == 'image_url': + url_data = part.get('image_url', {}) + url = url_data.get('url', '') if isinstance(url_data, dict) else url_data + content_parts.append({'type': 'input_image', 'image_url': url}) else: - content_parts = [{"type": text_type, "text": str(content)}] + content_parts = [{'type': text_type, 'text': str(content)}] + + input_items.append({'type': 'message', 'role': role, 'content': content_parts}) - input_items.append({"type": "message", "role": role, "content": content_parts}) + responses_payload = {**payload, 'input': input_items} - responses_payload = {**payload, "input": input_items} + # Forward previous_response_id when the middleware has set it + # (only used when ENABLE_RESPONSES_API_STATEFUL is enabled). + previous_response_id = responses_payload.pop('previous_response_id', None) + if previous_response_id: + responses_payload['previous_response_id'] = previous_response_id if system_content: - responses_payload["instructions"] = system_content + responses_payload['instructions'] = system_content - if "max_tokens" in responses_payload: - responses_payload["max_output_tokens"] = responses_payload.pop("max_tokens") + if 'max_tokens' in responses_payload: + responses_payload['max_output_tokens'] = responses_payload.pop('max_tokens') + + if 'max_completion_tokens' in responses_payload: + responses_payload['max_output_tokens'] = responses_payload.pop('max_completion_tokens') # Remove Chat Completions-only parameters not supported by the Responses API for unsupported_key in ( - "stream_options", - "logit_bias", - "frequency_penalty", - "presence_penalty", - "stop", + 'stream_options', + 'logit_bias', + 'frequency_penalty', + 'presence_penalty', + 'stop', ): responses_payload.pop(unsupported_key, None) # Convert Chat Completions tools format to Responses API format # Chat Completions: {"type": "function", "function": {"name": ..., "description": ..., "parameters": ...}} # Responses API: {"type": "function", "name": ..., "description": ..., "parameters": ...} - if "tools" in responses_payload and isinstance(responses_payload["tools"], list): + if 'tools' in responses_payload and isinstance(responses_payload['tools'], list): converted_tools = [] - for tool in responses_payload["tools"]: - if isinstance(tool, dict) and "function" in tool: - func = tool["function"] - converted_tool = {"type": tool.get("type", "function")} + for tool in responses_payload['tools']: + if isinstance(tool, dict) and 'function' in tool: + func = tool['function'] + converted_tool = {'type': tool.get('type', 'function')} if isinstance(func, dict): - converted_tool["name"] = func.get("name", "") - if "description" in func: - converted_tool["description"] = func["description"] - if "parameters" in func: - converted_tool["parameters"] = func["parameters"] - if "strict" in func: - converted_tool["strict"] = func["strict"] + converted_tool['name'] = func.get('name', '') + if 'description' in func: + converted_tool['description'] = func['description'] + if 'parameters' in func: + converted_tool['parameters'] = func['parameters'] + if 'strict' in func: + converted_tool['strict'] = func['strict'] converted_tools.append(converted_tool) else: # Already in correct format or unknown format, pass through converted_tools.append(tool) - responses_payload["tools"] = converted_tools + responses_payload['tools'] = converted_tools return responses_payload def convert_responses_result(response: dict) -> dict: """ - Convert non-streaming Responses API result. - Just add done flag - pass through raw response, frontend handles output. + Convert non-streaming Responses API result to Chat Completions format. + + Extracts text from message output items so all downstream consumers + (frontend tasks, get_content_from_response) work without modification. """ - response["done"] = True - return response + output_items = response.get('output', []) + content = '' + for item in output_items: + if item.get('type') == 'message': + for part in item.get('content', []): + if part.get('type') == 'output_text': + content += part.get('text', '') -@router.post("/chat/completions") + return { + 'id': response.get('id', ''), + 'object': 'chat.completion', + 'model': response.get('model', ''), + 'choices': [ + { + 'index': 0, + 'message': { + 'role': 'assistant', + 'content': content, + }, + 'finish_reason': 'stop', + } + ], + 'usage': response.get('usage', {}), + } + + +@router.post('/chat/completions') async def generate_chat_completion( request: Request, form_data: dict, user=Depends(get_verified_user), - bypass_filter: Optional[bool] = False, bypass_system_prompt: bool = False, ): # NOTE: We intentionally do NOT use Depends(get_session) here. @@ -958,61 +1030,61 @@ async def generate_chat_completion( check_credit_by_user_id(user_id=user.id, form_data=form_data) + # bypass_filter is read from request.state to prevent external clients from + # setting it via query parameter (CVE fix). Only internal server-side callers + # (e.g. utils/chat.py) should set request.state.bypass_filter = True. + bypass_filter = getattr(request.state, 'bypass_filter', False) if BYPASS_MODEL_ACCESS_CONTROL: bypass_filter = True idx = 0 payload = {**form_data} - metadata = payload.pop("metadata", None) + metadata = payload.pop('metadata', None) - model_id = form_data.get("model") + model_id = form_data.get('model') model_info = Models.get_model_by_id(model_id) # Check model info and override the payload if model_info: if model_info.base_model_id: base_model_id = ( - request.base_model_id - if hasattr(request, "base_model_id") - else model_info.base_model_id + request.base_model_id if hasattr(request, 'base_model_id') else model_info.base_model_id ) # Use request's base_model_id if available - payload["model"] = base_model_id + payload['model'] = base_model_id model_id = base_model_id params = model_info.params.model_dump() if params: - system = params.pop("system", None) + system = params.pop('system', None) payload = apply_model_params_to_body_openai(params, payload) if not bypass_system_prompt: payload = apply_system_prompt_to_body(system, payload, metadata, user) # Check if user has access to the model - if not bypass_filter and user.role == "user": - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id) - } + if not bypass_filter and user.role == 'user': + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} if not ( user.id == model_info.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model_info.id, - permission="read", + permission='read', user_group_ids=user_group_ids, ) ): raise HTTPException( status_code=403, - detail="Model not found", + detail='Model not found', ) elif not bypass_filter: - if user.role != "admin": + if user.role != 'admin': raise HTTPException( status_code=403, - detail="Model not found", + detail='Model not found', ) # Check if model is already in app state cache to avoid expensive get_all_models() call @@ -1023,11 +1095,11 @@ async def generate_chat_completion( model = models.get(model_id) if model: - idx = model["urlIdx"] + idx = model['urlIdx'] else: raise HTTPException( status_code=404, - detail="Model not found", + detail='Model not found', ) # Get the API config for the model @@ -1038,69 +1110,75 @@ async def generate_chat_completion( ), # Legacy support ) - prefix_id = api_config.get("prefix_id", None) + prefix_id = api_config.get('prefix_id', None) if prefix_id: - payload["model"] = payload["model"].replace(f"{prefix_id}.", "") + payload['model'] = payload['model'].replace(f'{prefix_id}.', '') # Add user info to the payload if the model is a pipeline - if "pipeline" in model and model.get("pipeline"): - payload["user"] = { - "name": user.name, - "id": user.id, - "email": user.email, - "role": user.role, + if 'pipeline' in model and model.get('pipeline'): + payload['user'] = { + 'name': user.name, + 'id': user.id, + 'email': user.email, + 'role': user.role, } url = request.app.state.config.OPENAI_API_BASE_URLS[idx] key = request.app.state.config.OPENAI_API_KEYS[idx] # Check if model is a reasoning model that needs special handling - if is_openai_reasoning_model(payload["model"]): + if is_openai_new_model(payload['model']): payload = openai_reasoning_model_handler(payload) - elif "api.openai.com" not in url: + elif 'api.openai.com' not in url: # Remove "max_completion_tokens" from the payload for backward compatibility - if "max_completion_tokens" in payload: - payload["max_tokens"] = payload["max_completion_tokens"] - del payload["max_completion_tokens"] + if 'max_completion_tokens' in payload: + payload['max_tokens'] = payload['max_completion_tokens'] + del payload['max_completion_tokens'] - if "max_tokens" in payload and "max_completion_tokens" in payload: - del payload["max_tokens"] + if 'max_tokens' in payload and 'max_completion_tokens' in payload: + del payload['max_tokens'] # Convert the modified body back to JSON - if "logit_bias" in payload and payload["logit_bias"]: - logit_bias = convert_logit_bias_input_to_json(payload["logit_bias"]) + if 'logit_bias' in payload and payload['logit_bias']: + logit_bias = convert_logit_bias_input_to_json(payload['logit_bias']) if logit_bias: - payload["logit_bias"] = json.loads(logit_bias) + payload['logit_bias'] = json.loads(logit_bias) - headers, cookies = await get_headers_and_cookies( - request, url, key, api_config, metadata, user=user - ) + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, metadata, user=user) - is_responses = api_config.get("api_type") == "responses" + is_responses = api_config.get('api_type') == 'responses' - if api_config.get("azure", False): - api_version = api_config.get("api_version", "2023-03-15-preview") + if api_config.get('azure', False): + api_version = api_config.get('api_version', '2023-03-15-preview') request_url, payload = convert_to_azure_payload(url, payload, api_version) # Only set api-key header if not using Azure Entra ID authentication - auth_type = api_config.get("auth_type", "bearer") - if auth_type not in ("azure_ad", "microsoft_entra_id"): - headers["api-key"] = key + auth_type = api_config.get('auth_type', 'bearer') + if auth_type not in ('azure_ad', 'microsoft_entra_id'): + headers['api-key'] = key - headers["api-version"] = api_version + headers['api-version'] = api_version if is_responses: payload = convert_to_responses_payload(payload) - request_url = f"{request_url}/responses?api-version={api_version}" + request_url = f'{request_url}/responses?api-version={api_version}' else: - request_url = f"{request_url}/chat/completions?api-version={api_version}" + request_url = f'{request_url}/chat/completions?api-version={api_version}' else: if is_responses: payload = convert_to_responses_payload(payload) - request_url = f"{url}/responses" + request_url = f'{url}/responses' else: - request_url = f"{url}/chat/completions" + request_url = f'{url}/chat/completions' + # For Chat Completions, strip image parts from multimodal tool messages + # (Chat Completions doesn't support images in tool content). + if not is_responses and 'messages' in payload: + for message in payload['messages']: + if message.get('role') == 'tool' and isinstance(message.get('content'), list): + message['content'] = ''.join( + part.get('text', '') for part in message['content'] if part.get('type') in ('input_text', 'text') + ) payload = json.dumps(payload) @@ -1117,7 +1195,7 @@ async def generate_chat_completion( ) r = await session.request( - method="POST", + method='POST', url=request_url, data=payload, headers=headers, @@ -1126,13 +1204,10 @@ async def generate_chat_completion( ) # Check if response is SSE - if "text/event-stream" in r.headers.get("Content-Type", ""): - + if 'text/event-stream' in r.headers.get('Content-Type', ''): streaming = True return StreamingResponse( - stream_wrapper( - user, model_id, form_data, r, session, stream_chunks_handler - ), + stream_wrapper(user, model_id, form_data, r, session, stream_chunks_handler), status_code=r.status, headers=dict(r.headers), ) @@ -1166,7 +1241,7 @@ async def generate_chat_completion( raise HTTPException( status_code=r.status if r else 500, - detail="Open WebUI: Server Connection Error", + detail='Open WebUI: Server Connection Error', ) finally: if not streaming: @@ -1194,14 +1269,14 @@ async def embeddings(request: Request, form_data: dict, user): # Prepare payload/body body = json.dumps(form_data) # Find correct backend url/key based on model - model_id = form_data.get("model") + model_id = form_data.get('model') # Check if model is already in app state cache to avoid expensive get_all_models() call 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 if model_id in models: - idx = models[model_id]["urlIdx"] + idx = models[model_id]['urlIdx'] url = request.app.state.config.OPENAI_API_BASE_URLS[idx] key = request.app.state.config.OPENAI_API_KEYS[idx] @@ -1214,34 +1289,30 @@ async def embeddings(request: Request, form_data: dict, user): session = None streaming = False - headers, cookies = await get_headers_and_cookies( - request, url, key, api_config, user=user - ) + headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user) try: session = aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT), ) r = await session.request( - method="POST", - url=f"{url}/embeddings", + method='POST', + url=f'{url}/embeddings', data=body, headers=headers, cookies=cookies, ) - if "text/event-stream" in r.headers.get("Content-Type", ""): + if 'text/event-stream' in r.headers.get('Content-Type', ''): if user: with CreditDeduct( user=user, model_id=model_id, - body={ - "messages": [{"role": "user", "content": form_data["input"]}] - }, + body={'messages': [{'role': 'user', 'content': form_data['input']}]}, is_stream=False, is_embedding=True, ) as credit_deduct: - credit_deduct.run(form_data["input"]) + credit_deduct.run(form_data['input']) streaming = True return StreamingResponse( stream_wrapper(user, model_id, form_data, r, session), @@ -1258,34 +1329,30 @@ async def embeddings(request: Request, form_data: dict, user): if isinstance(response_data, (dict, list)): return JSONResponse(status_code=r.status, content=response_data) else: - return PlainTextResponse( - status_code=r.status, content=response_data - ) + return PlainTextResponse(status_code=r.status, content=response_data) if user: with CreditDeduct( user=user, model_id=model_id, - body={ - "messages": [{"role": "user", "content": form_data["input"]}] - }, + body={'messages': [{'role': 'user', 'content': form_data['input']}]}, is_stream=False, is_embedding=True, ) as credit_deduct: - if "usage" in response_data: + if 'usage' in response_data: credit_deduct.is_official_usage = True - prompt_tokens = response_data["usage"]["prompt_tokens"] + prompt_tokens = response_data['usage']['prompt_tokens'] credit_deduct.usage.prompt_tokens = prompt_tokens credit_deduct.usage.total_tokens = prompt_tokens else: - credit_deduct.run(form_data["input"]) + credit_deduct.run(form_data['input']) return response_data except Exception as e: log.exception(e) raise HTTPException( status_code=r.status if r else 500, - detail="Open WebUI: Server Connection Error", + detail='Open WebUI: Server Connection Error', ) finally: if not streaming: @@ -1293,7 +1360,7 @@ async def embeddings(request: Request, form_data: dict, user): class ResponsesForm(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') model: str input: Optional[list | str] = None diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index fa1b77a09c..94c1357fd7 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -32,6 +32,8 @@ ################################## # # Pipeline Middleware +# Every hand this passes through can corrupt it or +# improve it. Let each stage leave it better than it found. # ################################## @@ -40,37 +42,34 @@ def get_sorted_filters(model_id, models): filters = [ model for model in models.values() - if "pipeline" in model - and "type" in model["pipeline"] - and model["pipeline"]["type"] == "filter" + if 'pipeline' in model + and 'type' in model['pipeline'] + and model['pipeline']['type'] == 'filter' and ( - model["pipeline"]["pipelines"] == ["*"] - or any( - model_id == target_model_id - for target_model_id in model["pipeline"]["pipelines"] - ) + model['pipeline']['pipelines'] == ['*'] + or any(model_id == target_model_id for target_model_id in model['pipeline']['pipelines']) ) ] - sorted_filters = sorted(filters, key=lambda x: x["pipeline"]["priority"]) + sorted_filters = sorted(filters, key=lambda x: x['pipeline']['priority']) return sorted_filters async def process_pipeline_inlet_filter(request, payload, user, models): - user = {"id": user.id, "email": user.email, "name": user.name, "role": user.role} - model_id = payload["model"] + user = {'id': user.id, 'email': user.email, 'name': user.name, 'role': user.role} + model_id = payload['model'] sorted_filters = get_sorted_filters(model_id, models) model = models[model_id] - if "pipeline" in model: + if 'pipeline' in model: sorted_filters.append(model) async with aiohttp.ClientSession(trust_env=True) as session: for filter in sorted_filters: - urlIdx = filter.get("urlIdx") + urlIdx = filter.get('urlIdx') try: urlIdx = int(urlIdx) - except: + except Exception: continue url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] @@ -79,15 +78,15 @@ async def process_pipeline_inlet_filter(request, payload, user, models): if not key: continue - headers = {"Authorization": f"Bearer {key}"} + headers = {'Authorization': f'Bearer {key}'} request_data = { - "user": user, - "body": payload, + 'user': user, + 'body': payload, } try: async with session.post( - f"{url}/{filter['id']}/filter/inlet", + f'{url}/{filter["id"]}/filter/inlet', headers=headers, json=request_data, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -95,35 +94,31 @@ async def process_pipeline_inlet_filter(request, payload, user, models): response.raise_for_status() payload = await response.json() except aiohttp.ClientResponseError as e: - res = ( - await response.json() - if response.content_type == "application/json" - else {} - ) - if "detail" in res: - raise Exception(response.status, res["detail"]) + res = await response.json() if response.content_type == 'application/json' else {} + if 'detail' in res: + raise Exception(response.status, res['detail']) except Exception as e: - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') return payload async def process_pipeline_outlet_filter(request, payload, user, models): - user = {"id": user.id, "email": user.email, "name": user.name, "role": user.role} - model_id = payload["model"] + user = {'id': user.id, 'email': user.email, 'name': user.name, 'role': user.role} + model_id = payload['model'] sorted_filters = get_sorted_filters(model_id, models) model = models[model_id] - if "pipeline" in model: + if 'pipeline' in model: sorted_filters = [model] + sorted_filters async with aiohttp.ClientSession(trust_env=True) as session: for filter in sorted_filters: - urlIdx = filter.get("urlIdx") + urlIdx = filter.get('urlIdx') try: urlIdx = int(urlIdx) - except: + except Exception: continue url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] @@ -132,15 +127,15 @@ async def process_pipeline_outlet_filter(request, payload, user, models): if not key: continue - headers = {"Authorization": f"Bearer {key}"} + headers = {'Authorization': f'Bearer {key}'} request_data = { - "user": user, - "body": payload, + 'user': user, + 'body': payload, } try: async with session.post( - f"{url}/{filter['id']}/filter/outlet", + f'{url}/{filter["id"]}/filter/outlet', headers=headers, json=request_data, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -149,17 +144,13 @@ async def process_pipeline_outlet_filter(request, payload, user, models): payload = await response.json() except aiohttp.ClientResponseError as e: try: - res = ( - await response.json() - if "application/json" in response.content_type - else {} - ) - if "detail" in res: + res = await response.json() if 'application/json' in response.content_type else {} + if 'detail' in res: raise Exception(response.status, res) except Exception: pass except Exception as e: - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') return payload @@ -173,72 +164,68 @@ async def process_pipeline_outlet_filter(request, payload, user, models): router = APIRouter() -@router.get("/list") +@router.get('/list') async def get_pipelines_list(request: Request, user=Depends(get_admin_user)): responses = await get_all_models_responses(request, user) - log.debug(f"get_pipelines_list: get_openai_models_responses returned {responses}") + log.debug(f'get_pipelines_list: get_openai_models_responses returned {responses}') - urlIdxs = [ - idx - for idx, response in enumerate(responses) - if response is not None and "pipelines" in response - ] + urlIdxs = [idx for idx, response in enumerate(responses) if response is not None and 'pipelines' in response] return { - "data": [ + 'data': [ { - "url": request.app.state.config.OPENAI_API_BASE_URLS[urlIdx], - "idx": urlIdx, + 'url': request.app.state.config.OPENAI_API_BASE_URLS[urlIdx], + 'idx': urlIdx, } for urlIdx in urlIdxs ] } -@router.post("/upload") +@router.post('/upload') async def upload_pipeline( request: Request, urlIdx: int = Form(...), file: UploadFile = File(...), user=Depends(get_admin_user), ): - log.info(f"upload_pipeline: urlIdx={urlIdx}, filename={file.filename}") + log.info(f'upload_pipeline: urlIdx={urlIdx}, filename={file.filename}') filename = os.path.basename(file.filename) # Check if the uploaded file is a python file - if not (filename and filename.endswith(".py")): + if not (filename and filename.endswith('.py')): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Only Python (.py) files are allowed.", + detail='Only Python (.py) files are allowed.', ) - upload_folder = f"{CACHE_DIR}/pipelines" + upload_folder = f'{CACHE_DIR}/pipelines' os.makedirs(upload_folder, exist_ok=True) file_path = os.path.join(upload_folder, filename) response = None try: # Save the uploaded file - with open(file_path, "wb") as buffer: + with open(file_path, 'wb') as buffer: shutil.copyfileobj(file.file, buffer) url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] key = request.app.state.config.OPENAI_API_KEYS[urlIdx] - headers = {"Authorization": f"Bearer {key}"} + headers = {'Authorization': f'Bearer {key}'} async with aiohttp.ClientSession(trust_env=True) as session: - with open(file_path, "rb") as f: + with open(file_path, 'rb') as f: form_data = aiohttp.FormData() form_data.add_field( - "file", + 'file', f, filename=filename, - content_type="application/octet-stream", + content_type='application/octet-stream', ) async with session.post( - f"{url}/pipelines/upload", + f'{url}/pipelines/upload', headers=headers, data=form_data, ssl=AIOHTTP_CLIENT_SESSION_SSL, @@ -249,7 +236,7 @@ async def upload_pipeline( return {**data} except Exception as e: # Handle connection error here - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') detail = None status_code = status.HTTP_404_NOT_FOUND @@ -257,14 +244,14 @@ async def upload_pipeline( status_code = response.status try: res = await response.json() - if "detail" in res: - detail = res["detail"] + if 'detail' in res: + detail = res['detail'] except Exception: pass raise HTTPException( status_code=status_code, - detail=detail if detail else "Pipeline not found", + detail=detail if detail else 'Pipeline not found', ) finally: # Ensure the file is deleted after the upload is completed or on failure @@ -277,10 +264,8 @@ class AddPipelineForm(BaseModel): urlIdx: int -@router.post("/add") -async def add_pipeline( - request: Request, form_data: AddPipelineForm, user=Depends(get_admin_user) -): +@router.post('/add') +async def add_pipeline(request: Request, form_data: AddPipelineForm, user=Depends(get_admin_user)): response = None try: urlIdx = form_data.urlIdx @@ -290,9 +275,9 @@ async def add_pipeline( async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( - f"{url}/pipelines/add", - headers={"Authorization": f"Bearer {key}"}, - json={"url": form_data.url}, + f'{url}/pipelines/add', + headers={'Authorization': f'Bearer {key}'}, + json={'url': form_data.url}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: response.raise_for_status() @@ -301,22 +286,20 @@ async def add_pipeline( return {**data} except Exception as e: # Handle connection error here - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') detail = None if response is not None: try: res = await response.json() - if "detail" in res: - detail = res["detail"] + if 'detail' in res: + detail = res['detail'] except Exception: pass raise HTTPException( - status_code=( - response.status if response is not None else status.HTTP_404_NOT_FOUND - ), - detail=detail if detail else "Pipeline not found", + status_code=(response.status if response is not None else status.HTTP_404_NOT_FOUND), + detail=detail if detail else 'Pipeline not found', ) @@ -325,10 +308,8 @@ class DeletePipelineForm(BaseModel): urlIdx: int -@router.delete("/delete") -async def delete_pipeline( - request: Request, form_data: DeletePipelineForm, user=Depends(get_admin_user) -): +@router.delete('/delete') +async def delete_pipeline(request: Request, form_data: DeletePipelineForm, user=Depends(get_admin_user)): response = None try: urlIdx = form_data.urlIdx @@ -338,9 +319,9 @@ async def delete_pipeline( async with aiohttp.ClientSession(trust_env=True) as session: async with session.delete( - f"{url}/pipelines/delete", - headers={"Authorization": f"Bearer {key}"}, - json={"id": form_data.id}, + f'{url}/pipelines/delete', + headers={'Authorization': f'Bearer {key}'}, + json={'id': form_data.id}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: response.raise_for_status() @@ -349,29 +330,25 @@ async def delete_pipeline( return {**data} except Exception as e: # Handle connection error here - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') detail = None if response is not None: try: res = await response.json() - if "detail" in res: - detail = res["detail"] + if 'detail' in res: + detail = res['detail'] except Exception: pass raise HTTPException( - status_code=( - response.status if response is not None else status.HTTP_404_NOT_FOUND - ), - detail=detail if detail else "Pipeline not found", + status_code=(response.status if response is not None else status.HTTP_404_NOT_FOUND), + detail=detail if detail else 'Pipeline not found', ) -@router.get("/") -async def get_pipelines( - request: Request, urlIdx: Optional[int] = None, user=Depends(get_admin_user) -): +@router.get('/') +async def get_pipelines(request: Request, urlIdx: Optional[int] = None, user=Depends(get_admin_user)): response = None try: url = request.app.state.config.OPENAI_API_BASE_URLS[urlIdx] @@ -379,8 +356,8 @@ async def get_pipelines( async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( - f"{url}/pipelines", - headers={"Authorization": f"Bearer {key}"}, + f'{url}/pipelines', + headers={'Authorization': f'Bearer {key}'}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: response.raise_for_status() @@ -389,26 +366,24 @@ async def get_pipelines( return {**data} except Exception as e: # Handle connection error here - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') detail = None if response is not None: try: res = await response.json() - if "detail" in res: - detail = res["detail"] + if 'detail' in res: + detail = res['detail'] except Exception: pass raise HTTPException( - status_code=( - response.status if response is not None else status.HTTP_404_NOT_FOUND - ), - detail=detail if detail else "Pipeline not found", + status_code=(response.status if response is not None else status.HTTP_404_NOT_FOUND), + detail=detail if detail else 'Pipeline not found', ) -@router.get("/{pipeline_id}/valves") +@router.get('/{pipeline_id}/valves') async def get_pipeline_valves( request: Request, urlIdx: Optional[int], @@ -422,8 +397,8 @@ async def get_pipeline_valves( async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( - f"{url}/{pipeline_id}/valves", - headers={"Authorization": f"Bearer {key}"}, + f'{url}/{pipeline_id}/valves', + headers={'Authorization': f'Bearer {key}'}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: response.raise_for_status() @@ -432,26 +407,24 @@ async def get_pipeline_valves( return {**data} except Exception as e: # Handle connection error here - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') detail = None if response is not None: try: res = await response.json() - if "detail" in res: - detail = res["detail"] + if 'detail' in res: + detail = res['detail'] except Exception: pass raise HTTPException( - status_code=( - response.status if response is not None else status.HTTP_404_NOT_FOUND - ), - detail=detail if detail else "Pipeline not found", + status_code=(response.status if response is not None else status.HTTP_404_NOT_FOUND), + detail=detail if detail else 'Pipeline not found', ) -@router.get("/{pipeline_id}/valves/spec") +@router.get('/{pipeline_id}/valves/spec') async def get_pipeline_valves_spec( request: Request, urlIdx: Optional[int], @@ -465,8 +438,8 @@ async def get_pipeline_valves_spec( async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( - f"{url}/{pipeline_id}/valves/spec", - headers={"Authorization": f"Bearer {key}"}, + f'{url}/{pipeline_id}/valves/spec', + headers={'Authorization': f'Bearer {key}'}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: response.raise_for_status() @@ -475,26 +448,24 @@ async def get_pipeline_valves_spec( return {**data} except Exception as e: # Handle connection error here - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') detail = None if response is not None: try: res = await response.json() - if "detail" in res: - detail = res["detail"] + if 'detail' in res: + detail = res['detail'] except Exception: pass raise HTTPException( - status_code=( - response.status if response is not None else status.HTTP_404_NOT_FOUND - ), - detail=detail if detail else "Pipeline not found", + status_code=(response.status if response is not None else status.HTTP_404_NOT_FOUND), + detail=detail if detail else 'Pipeline not found', ) -@router.post("/{pipeline_id}/valves/update") +@router.post('/{pipeline_id}/valves/update') async def update_pipeline_valves( request: Request, urlIdx: Optional[int], @@ -509,8 +480,8 @@ async def update_pipeline_valves( async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( - f"{url}/{pipeline_id}/valves/update", - headers={"Authorization": f"Bearer {key}"}, + f'{url}/{pipeline_id}/valves/update', + headers={'Authorization': f'Bearer {key}'}, json={**form_data}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: @@ -520,21 +491,19 @@ async def update_pipeline_valves( return {**data} except Exception as e: # Handle connection error here - log.exception(f"Connection error: {e}") + log.exception(f'Connection error: {e}') detail = None if response is not None: try: res = await response.json() - if "detail" in res: - detail = res["detail"] + if 'detail' in res: + detail = res['detail'] except Exception: pass raise HTTPException( - status_code=( - response.status if response is not None else status.HTTP_404_NOT_FOUND - ), - detail=detail if detail else "Pipeline not found", + status_code=(response.status if response is not None else status.HTTP_404_NOT_FOUND), + detail=detail if detail else 'Pipeline not found', ) diff --git a/backend/open_webui/routers/prompts.py b/backend/open_webui/routers/prompts.py index 1f3342dad7..e4af8bb513 100644 --- a/backend/open_webui/routers/prompts.py +++ b/backend/open_webui/routers/prompts.py @@ -42,29 +42,27 @@ class PromptMetadataForm(BaseModel): ############################ # GetPrompts +# The hardest part is knowing what to ask. Let the right +# question already be here when it is needed. ############################ -@router.get("/", response_model=list[PromptModel]) -async def get_prompts( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: +@router.get('/', response_model=list[PromptModel]) +async def get_prompts(user=Depends(get_verified_user), db: Session = Depends(get_session)): + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: prompts = Prompts.get_prompts(db=db) else: - prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db) + prompts = Prompts.get_prompts_by_user_id(user.id, 'read', db=db) return prompts -@router.get("/tags", response_model=list[str]) -async def get_prompt_tags( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: +@router.get('/tags', response_model=list[str]) +async def get_prompt_tags(user=Depends(get_verified_user), db: Session = Depends(get_session)): + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: return Prompts.get_tags(db=db) else: - prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db) + prompts = Prompts.get_prompts_by_user_id(user.id, 'read', db=db) tags = set() for prompt in prompts: if prompt.tags: @@ -72,7 +70,7 @@ async def get_prompt_tags( return sorted(list(tags)) -@router.get("/list", response_model=PromptAccessListResponse) +@router.get('/list', response_model=PromptAccessListResponse) async def get_prompt_list( query: Optional[str] = None, view_option: Optional[str] = None, @@ -90,37 +88,35 @@ async def get_prompt_list( filter = {} if query: - filter["query"] = query + filter['query'] = query if view_option: - filter["view_option"] = view_option + filter['view_option'] = view_option if tag: - filter["tag"] = tag + filter['tag'] = tag if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction # Pre-fetch user group IDs once - used for both filter and write_access check groups = Groups.get_groups_by_member_id(user.id, db=db) user_group_ids = {group.id for group in groups} - if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL): + if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL): if groups: - filter["group_ids"] = [group.id for group in groups] + filter['group_ids'] = [group.id for group in groups] - filter["user_id"] = user.id + filter['user_id'] = user.id - result = Prompts.search_prompts( - user.id, filter=filter, skip=skip, limit=limit, db=db - ) + result = Prompts.search_prompts(user.id, filter=filter, skip=skip, limit=limit, db=db) # Batch-fetch writable prompt IDs in a single query instead of N has_access calls prompt_ids = [prompt.id for prompt in result.items] writable_prompt_ids = AccessGrants.get_accessible_resource_ids( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_ids=prompt_ids, - permission="write", + permission='write', user_group_ids=user_group_ids, db=db, ) @@ -130,7 +126,7 @@ async def get_prompt_list( PromptAccessResponse( **prompt.model_dump(), write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == prompt.user_id or prompt.id in writable_prompt_ids ), @@ -146,23 +142,23 @@ async def get_prompt_list( ############################ -@router.post("/create", response_model=Optional[PromptModel]) +@router.post('/create', response_model=Optional[PromptModel]) async def create_new_prompt( request: Request, form_data: PromptForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not ( + if user.role != 'admin' and not ( has_permission( user.id, - "workspace.prompts", + 'workspace.prompts', request.app.state.config.USER_PERMISSIONS, db=db, ) or has_permission( user.id, - "workspace.prompts_import", + 'workspace.prompts_import', request.app.state.config.USER_PERMISSIONS, db=db, ) @@ -193,34 +189,32 @@ async def create_new_prompt( ############################ -@router.get("/command/{command}", response_model=Optional[PromptAccessResponse]) -async def get_prompt_by_command( - command: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/command/{command}', response_model=Optional[PromptAccessResponse]) +async def get_prompt_by_command(command: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): prompt = Prompts.get_prompt_by_command(command, db=db) if prompt: if ( - user.role == "admin" + user.role == 'admin' or prompt.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="read", + permission='read', db=db, ) ): return PromptAccessResponse( **prompt.model_dump(), write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == prompt.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) ), @@ -237,34 +231,32 @@ async def get_prompt_by_command( ############################ -@router.get("/id/{prompt_id}", response_model=Optional[PromptAccessResponse]) -async def get_prompt_by_id( - prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/id/{prompt_id}', response_model=Optional[PromptAccessResponse]) +async def get_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): prompt = Prompts.get_prompt_by_id(prompt_id, db=db) if prompt: if ( - user.role == "admin" + user.role == 'admin' or prompt.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="read", + permission='read', db=db, ) ): return PromptAccessResponse( **prompt.model_dump(), write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == prompt.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) ), @@ -281,7 +273,7 @@ async def get_prompt_by_id( ############################ -@router.post("/id/{prompt_id}/update", response_model=Optional[PromptModel]) +@router.post('/id/{prompt_id}/update', response_model=Optional[PromptModel]) async def update_prompt_by_id( prompt_id: str, form_data: PromptForm, @@ -301,12 +293,12 @@ async def update_prompt_by_id( prompt.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -338,7 +330,7 @@ async def update_prompt_by_id( ############################ -@router.post("/id/{prompt_id}/update/meta", response_model=Optional[PromptModel]) +@router.post('/id/{prompt_id}/update/meta', response_model=Optional[PromptModel]) async def update_prompt_metadata( prompt_id: str, form_data: PromptMetadataForm, @@ -358,12 +350,12 @@ async def update_prompt_metadata( prompt.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -379,9 +371,7 @@ async def update_prompt_metadata( detail=f"Command '/{form_data.command}' is already in use", ) - updated_prompt = Prompts.update_prompt_metadata( - prompt.id, form_data.name, form_data.command, form_data.tags, db=db - ) + updated_prompt = Prompts.update_prompt_metadata(prompt.id, form_data.name, form_data.command, form_data.tags, db=db) if updated_prompt: return updated_prompt else: @@ -391,7 +381,7 @@ async def update_prompt_metadata( ) -@router.post("/id/{prompt_id}/update/version", response_model=Optional[PromptModel]) +@router.post('/id/{prompt_id}/update/version', response_model=Optional[PromptModel]) async def set_prompt_version( prompt_id: str, form_data: PromptVersionUpdateForm, @@ -409,21 +399,19 @@ async def set_prompt_version( prompt.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - updated_prompt = Prompts.update_prompt_version( - prompt.id, form_data.version_id, db=db - ) + updated_prompt = Prompts.update_prompt_version(prompt.id, form_data.version_id, db=db) if updated_prompt: return updated_prompt else: @@ -442,7 +430,7 @@ class PromptAccessGrantsForm(BaseModel): access_grants: list[dict] -@router.post("/id/{prompt_id}/access/update", response_model=Optional[PromptModel]) +@router.post('/id/{prompt_id}/access/update', response_model=Optional[PromptModel]) async def update_prompt_access_by_id( request: Request, prompt_id: str, @@ -461,12 +449,12 @@ async def update_prompt_access_by_id( prompt.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -478,10 +466,10 @@ async def update_prompt_access_by_id( user.id, user.role, form_data.access_grants, - "sharing.public_prompts", + 'sharing.public_prompts', ) - AccessGrants.set_access_grants("prompt", prompt_id, form_data.access_grants, db=db) + AccessGrants.set_access_grants('prompt', prompt_id, form_data.access_grants, db=db) return Prompts.get_prompt_by_id(prompt_id, db=db) @@ -491,10 +479,8 @@ async def update_prompt_access_by_id( ############################ -@router.post("/id/{prompt_id}/toggle", response_model=Optional[PromptModel]) -async def toggle_prompt_active( - prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.post('/id/{prompt_id}/toggle', response_model=Optional[PromptModel]) +async def toggle_prompt_active(prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): prompt = Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: @@ -507,12 +493,12 @@ async def toggle_prompt_active( prompt.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -533,10 +519,8 @@ async def toggle_prompt_active( ############################ -@router.delete("/id/{prompt_id}/delete", response_model=bool) -async def delete_prompt_by_id( - prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.delete('/id/{prompt_id}/delete', response_model=bool) +async def delete_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): prompt = Prompts.get_prompt_by_id(prompt_id, db=db) if not prompt: @@ -549,12 +533,12 @@ async def delete_prompt_by_id( prompt.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -570,7 +554,7 @@ async def delete_prompt_by_id( ############################ -@router.get("/id/{prompt_id}/history", response_model=list[PromptHistoryResponse]) +@router.get('/id/{prompt_id}/history', response_model=list[PromptHistoryResponse]) async def get_prompt_history( prompt_id: str, page: int = 0, @@ -590,13 +574,13 @@ async def get_prompt_history( # Check read access if not ( - user.role == "admin" + user.role == 'admin' or prompt.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="read", + permission='read', db=db, ) ): @@ -605,13 +589,11 @@ async def get_prompt_history( detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - history = PromptHistories.get_history_by_prompt_id( - prompt.id, limit=PAGE_SIZE, offset=page * PAGE_SIZE, db=db - ) + history = PromptHistories.get_history_by_prompt_id(prompt.id, limit=PAGE_SIZE, offset=page * PAGE_SIZE, db=db) return history -@router.get("/id/{prompt_id}/history/{history_id}", response_model=PromptHistoryModel) +@router.get('/id/{prompt_id}/history/{history_id}', response_model=PromptHistoryModel) async def get_prompt_history_entry( prompt_id: str, history_id: str, @@ -629,13 +611,13 @@ async def get_prompt_history_entry( # Check read access if not ( - user.role == "admin" + user.role == 'admin' or prompt.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="read", + permission='read', db=db, ) ): @@ -654,7 +636,7 @@ async def get_prompt_history_entry( return history_entry -@router.delete("/id/{prompt_id}/history/{history_id}", response_model=bool) +@router.delete('/id/{prompt_id}/history/{history_id}', response_model=bool) async def delete_prompt_history_entry( prompt_id: str, history_id: str, @@ -672,13 +654,13 @@ async def delete_prompt_history_entry( # Check write access if not ( - user.role == "admin" + user.role == 'admin' or prompt.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="write", + permission='write', db=db, ) ): @@ -691,7 +673,7 @@ async def delete_prompt_history_entry( if prompt.version_id == history_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot delete the active production version", + detail='Cannot delete the active production version', ) success = PromptHistories.delete_history_entry(history_id, db=db) @@ -704,7 +686,7 @@ async def delete_prompt_history_entry( return success -@router.get("/id/{prompt_id}/history/diff") +@router.get('/id/{prompt_id}/history/diff') async def get_prompt_diff( prompt_id: str, from_id: str, @@ -723,13 +705,13 @@ async def get_prompt_diff( # Check read access if not ( - user.role == "admin" + user.role == 'admin' or prompt.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="prompt", + resource_type='prompt', resource_id=prompt.id, - permission="read", + permission='read', db=db, ) ): @@ -742,7 +724,7 @@ async def get_prompt_diff( if not diff: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="One or both history entries not found", + detail='One or both history entries not found', ) return diff diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index e75b345ce8..6c9e988dd6 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -127,6 +127,8 @@ ########################################## # # Utility functions +# Give us this day our relevant chunks, and lead us +# not into hallucination, but deliver us from noise. # ########################################## @@ -137,7 +139,7 @@ def get_ef( auto_update: bool = RAG_EMBEDDING_MODEL_AUTO_UPDATE, ): ef = None - if embedding_model and engine == "": + if embedding_model and engine == '': from sentence_transformers import SentenceTransformer try: @@ -149,39 +151,37 @@ def get_ef( model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS, ) except Exception as e: - log.debug(f"Error loading SentenceTransformer: {e}") + log.debug(f'Error loading SentenceTransformer: {e}') return ef def get_rf( - engine: str = "", + engine: str = '', reranking_model: Optional[str] = None, - external_reranker_url: str = "", - external_reranker_api_key: str = "", - external_reranker_timeout: str = "", + external_reranker_url: str = '', + external_reranker_api_key: str = '', + external_reranker_timeout: str = '', auto_update: bool = RAG_RERANKING_MODEL_AUTO_UPDATE, ): rf = None # Convert timeout string to int or None (system default) - timeout_value = ( - int(external_reranker_timeout) if external_reranker_timeout else None - ) + timeout_value = int(external_reranker_timeout) if external_reranker_timeout else None if reranking_model: - if any(model in reranking_model for model in ["jinaai/jina-colbert-v2"]): + if any(model in reranking_model for model in ['jinaai/jina-colbert-v2']): try: from open_webui.retrieval.models.colbert import ColBERT rf = ColBERT( get_model_path(reranking_model, auto_update), - env="docker" if DOCKER else None, + env='docker' if DOCKER else None, ) except Exception as e: - log.error(f"ColBERT: {e}") + log.error(f'ColBERT: {e}') raise Exception(ERROR_MESSAGES.DEFAULT(e)) else: - if engine == "external": + if engine == 'external': try: from open_webui.retrieval.models.external import ExternalReranker @@ -192,7 +192,7 @@ def get_rf( timeout=timeout_value, ) except Exception as e: - log.error(f"ExternalReranking: {e}") + log.error(f'ExternalReranking: {e}') raise Exception(ERROR_MESSAGES.DEFAULT(e)) else: import sentence_transformers @@ -212,28 +212,24 @@ def get_rf( ), ) except Exception as e: - log.error(f"CrossEncoder: {e}") - raise Exception(ERROR_MESSAGES.DEFAULT("CrossEncoder error")) + log.error(f'CrossEncoder: {e}') + raise Exception(ERROR_MESSAGES.DEFAULT('CrossEncoder error')) # Safely adjust pad_token_id if missing as some models do not have this in config try: - model_cfg = getattr(rf, "model", None) - if model_cfg and hasattr(model_cfg, "config"): + model_cfg = getattr(rf, 'model', None) + if model_cfg and hasattr(model_cfg, 'config'): cfg = model_cfg.config - if getattr(cfg, "pad_token_id", None) is None: + if getattr(cfg, 'pad_token_id', None) is None: # Fallback to eos_token_id when available - eos = getattr(cfg, "eos_token_id", None) + eos = getattr(cfg, 'eos_token_id', None) if eos is not None: cfg.pad_token_id = eos - log.debug( - f"Missing pad_token_id detected; set to eos_token_id={eos}" - ) + log.debug(f'Missing pad_token_id detected; set to eos_token_id={eos}') else: - log.warning( - "Neither pad_token_id nor eos_token_id present in model config" - ) + log.warning('Neither pad_token_id nor eos_token_id present in model config') except Exception as e2: - log.warning(f"Failed to adjust pad_token_id on CrossEncoder: {e2}") + log.warning(f'Failed to adjust pad_token_id on CrossEncoder: {e2}') return rf @@ -260,43 +256,43 @@ class SearchForm(BaseModel): queries: List[str] -@router.get("/") +@router.get('/') async def get_status(request: Request): return { - "status": True, - "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, - "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, - "RAG_TEMPLATE": request.app.state.config.RAG_TEMPLATE, - "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE, - "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL, - "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, - "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, - "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, - "RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, + 'status': True, + 'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE, + 'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP, + 'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE, + 'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE, + 'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL, + 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, + 'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + 'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING, + 'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, } -@router.get("/embedding") +@router.get('/embedding') async def get_embedding_config(request: Request, user=Depends(get_admin_user)): return { - "status": True, - "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE, - "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL, - "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, - "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, - "RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, - "openai_config": { - "url": request.app.state.config.RAG_OPENAI_API_BASE_URL, - "key": request.app.state.config.RAG_OPENAI_API_KEY, + 'status': True, + 'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE, + 'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL, + 'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + 'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING, + 'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, + 'openai_config': { + 'url': request.app.state.config.RAG_OPENAI_API_BASE_URL, + 'key': request.app.state.config.RAG_OPENAI_API_KEY, }, - "ollama_config": { - "url": request.app.state.config.RAG_OLLAMA_BASE_URL, - "key": request.app.state.config.RAG_OLLAMA_API_KEY, + 'ollama_config': { + 'url': request.app.state.config.RAG_OLLAMA_BASE_URL, + 'key': request.app.state.config.RAG_OLLAMA_API_KEY, }, - "azure_openai_config": { - "url": request.app.state.config.RAG_AZURE_OPENAI_BASE_URL, - "key": request.app.state.config.RAG_AZURE_OPENAI_API_KEY, - "version": request.app.state.config.RAG_AZURE_OPENAI_API_VERSION, + 'azure_openai_config': { + 'url': request.app.state.config.RAG_AZURE_OPENAI_BASE_URL, + 'key': request.app.state.config.RAG_AZURE_OPENAI_API_KEY, + 'version': request.app.state.config.RAG_AZURE_OPENAI_API_VERSION, }, } @@ -329,72 +325,50 @@ class EmbeddingModelUpdateForm(BaseModel): def unload_embedding_model(request: Request): - if request.app.state.config.RAG_EMBEDDING_ENGINE == "": + if request.app.state.config.RAG_EMBEDDING_ENGINE == '': # unloads current internal embedding model and clears VRAM cache request.app.state.ef = None request.app.state.EMBEDDING_FUNCTION = None import gc gc.collect() - if DEVICE_TYPE == "cuda": + if DEVICE_TYPE == 'cuda': import torch if torch.cuda.is_available(): torch.cuda.empty_cache() -@router.post("/embedding/update") -async def update_embedding_config( - request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user) -): +@router.post('/embedding/update') +async def update_embedding_config(request: Request, form_data: EmbeddingModelUpdateForm, user=Depends(get_admin_user)): log.info( - f"Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.RAG_EMBEDDING_MODEL}" + f'Updating embedding model: {request.app.state.config.RAG_EMBEDDING_MODEL} to {form_data.RAG_EMBEDDING_MODEL}' ) unload_embedding_model(request) try: request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL - request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = ( - form_data.RAG_EMBEDDING_BATCH_SIZE - ) - request.app.state.config.ENABLE_ASYNC_EMBEDDING = ( - form_data.ENABLE_ASYNC_EMBEDDING - ) - request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = ( - form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS - ) + request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = form_data.RAG_EMBEDDING_BATCH_SIZE + request.app.state.config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING + request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS if request.app.state.config.RAG_EMBEDDING_ENGINE in [ - "ollama", - "openai", - "azure_openai", + 'ollama', + 'openai', + 'azure_openai', ]: if form_data.openai_config is not None: - request.app.state.config.RAG_OPENAI_API_BASE_URL = ( - form_data.openai_config.url - ) - request.app.state.config.RAG_OPENAI_API_KEY = ( - form_data.openai_config.key - ) + request.app.state.config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url + request.app.state.config.RAG_OPENAI_API_KEY = form_data.openai_config.key if form_data.ollama_config is not None: - request.app.state.config.RAG_OLLAMA_BASE_URL = ( - form_data.ollama_config.url - ) - request.app.state.config.RAG_OLLAMA_API_KEY = ( - form_data.ollama_config.key - ) + request.app.state.config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url + request.app.state.config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key if form_data.azure_openai_config is not None: - request.app.state.config.RAG_AZURE_OPENAI_BASE_URL = ( - form_data.azure_openai_config.url - ) - request.app.state.config.RAG_AZURE_OPENAI_API_KEY = ( - form_data.azure_openai_config.key - ) - request.app.state.config.RAG_AZURE_OPENAI_API_VERSION = ( - form_data.azure_openai_config.version - ) + request.app.state.config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url + request.app.state.config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key + request.app.state.config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version request.app.state.ef = get_ef( request.app.state.config.RAG_EMBEDDING_ENGINE, @@ -407,26 +381,26 @@ async def update_embedding_config( request.app.state.ef, ( request.app.state.config.RAG_OPENAI_API_BASE_URL - if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'openai' else ( request.app.state.config.RAG_OLLAMA_BASE_URL - if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' else request.app.state.config.RAG_AZURE_OPENAI_BASE_URL ) ), ( request.app.state.config.RAG_OPENAI_API_KEY - if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'openai' else ( request.app.state.config.RAG_OLLAMA_API_KEY - if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' else request.app.state.config.RAG_AZURE_OPENAI_API_KEY ) ), request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, azure_api_version=( request.app.state.config.RAG_AZURE_OPENAI_API_VERSION - if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'azure_openai' else None ), enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING, @@ -434,166 +408,167 @@ async def update_embedding_config( ) return { - "status": True, - "RAG_EMBEDDING_ENGINE": request.app.state.config.RAG_EMBEDDING_ENGINE, - "RAG_EMBEDDING_MODEL": request.app.state.config.RAG_EMBEDDING_MODEL, - "RAG_EMBEDDING_BATCH_SIZE": request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, - "ENABLE_ASYNC_EMBEDDING": request.app.state.config.ENABLE_ASYNC_EMBEDDING, - "RAG_EMBEDDING_CONCURRENT_REQUESTS": request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, - "openai_config": { - "url": request.app.state.config.RAG_OPENAI_API_BASE_URL, - "key": request.app.state.config.RAG_OPENAI_API_KEY, + 'status': True, + 'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE, + 'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL, + 'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, + 'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING, + 'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS, + 'openai_config': { + 'url': request.app.state.config.RAG_OPENAI_API_BASE_URL, + 'key': request.app.state.config.RAG_OPENAI_API_KEY, }, - "ollama_config": { - "url": request.app.state.config.RAG_OLLAMA_BASE_URL, - "key": request.app.state.config.RAG_OLLAMA_API_KEY, + 'ollama_config': { + 'url': request.app.state.config.RAG_OLLAMA_BASE_URL, + 'key': request.app.state.config.RAG_OLLAMA_API_KEY, }, - "azure_openai_config": { - "url": request.app.state.config.RAG_AZURE_OPENAI_BASE_URL, - "key": request.app.state.config.RAG_AZURE_OPENAI_API_KEY, - "version": request.app.state.config.RAG_AZURE_OPENAI_API_VERSION, + 'azure_openai_config': { + 'url': request.app.state.config.RAG_AZURE_OPENAI_BASE_URL, + 'key': request.app.state.config.RAG_AZURE_OPENAI_API_KEY, + 'version': request.app.state.config.RAG_AZURE_OPENAI_API_VERSION, }, } except Exception as e: - log.exception(f"Problem updating embedding model: {e}") + log.exception(f'Problem updating embedding model: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT(e), ) -@router.get("/config") +@router.get('/config') async def get_rag_config(request: Request, user=Depends(get_admin_user)): return { - "status": True, + 'status': True, # RAG settings - "RAG_TEMPLATE": request.app.state.config.RAG_TEMPLATE, - "TOP_K": request.app.state.config.TOP_K, - "BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, - "RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT, + 'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE, + 'TOP_K': request.app.state.config.TOP_K, + 'BYPASS_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, + 'RAG_FULL_CONTEXT': request.app.state.config.RAG_FULL_CONTEXT, # Hybrid search settings - "ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, - "ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, - "TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER, - "RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD, - "HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT, + 'ENABLE_RAG_HYBRID_SEARCH': request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, + 'ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS': request.app.state.config.ENABLE_RAG_HYBRID_SEARCH_ENRICHED_TEXTS, + 'TOP_K_RERANKER': request.app.state.config.TOP_K_RERANKER, + 'RELEVANCE_THRESHOLD': request.app.state.config.RELEVANCE_THRESHOLD, + 'HYBRID_BM25_WEIGHT': request.app.state.config.HYBRID_BM25_WEIGHT, # Content extraction settings - "CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE, - "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, - "PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE, - "DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY, - "DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL, - "DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, - "DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE, - "DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR, - "DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE, - "DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, - "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, - "DATALAB_MARKER_FORMAT_LINES": request.app.state.config.DATALAB_MARKER_FORMAT_LINES, - "DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM, - "DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, - "EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, - "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, - "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, - "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, - "DOCLING_API_KEY": request.app.state.config.DOCLING_API_KEY, - "DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS, - "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, - "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, - "DOCUMENT_INTELLIGENCE_MODEL": request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, - "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL, - "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, + 'CONTENT_EXTRACTION_ENGINE': request.app.state.config.CONTENT_EXTRACTION_ENGINE, + 'PDF_EXTRACT_IMAGES': request.app.state.config.PDF_EXTRACT_IMAGES, + 'PDF_LOADER_MODE': request.app.state.config.PDF_LOADER_MODE, + 'DATALAB_MARKER_API_KEY': request.app.state.config.DATALAB_MARKER_API_KEY, + 'DATALAB_MARKER_API_BASE_URL': request.app.state.config.DATALAB_MARKER_API_BASE_URL, + 'DATALAB_MARKER_ADDITIONAL_CONFIG': request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, + 'DATALAB_MARKER_SKIP_CACHE': request.app.state.config.DATALAB_MARKER_SKIP_CACHE, + 'DATALAB_MARKER_FORCE_OCR': request.app.state.config.DATALAB_MARKER_FORCE_OCR, + 'DATALAB_MARKER_PAGINATE': request.app.state.config.DATALAB_MARKER_PAGINATE, + 'DATALAB_MARKER_STRIP_EXISTING_OCR': request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, + 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + 'DATALAB_MARKER_FORMAT_LINES': request.app.state.config.DATALAB_MARKER_FORMAT_LINES, + 'DATALAB_MARKER_USE_LLM': request.app.state.config.DATALAB_MARKER_USE_LLM, + 'DATALAB_MARKER_OUTPUT_FORMAT': request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, + 'EXTERNAL_DOCUMENT_LOADER_URL': request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, + 'EXTERNAL_DOCUMENT_LOADER_API_KEY': request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, + 'TIKA_SERVER_URL': request.app.state.config.TIKA_SERVER_URL, + 'DOCLING_SERVER_URL': request.app.state.config.DOCLING_SERVER_URL, + 'DOCLING_API_KEY': request.app.state.config.DOCLING_API_KEY, + 'DOCLING_PARAMS': request.app.state.config.DOCLING_PARAMS, + 'DOCUMENT_INTELLIGENCE_ENDPOINT': request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, + 'DOCUMENT_INTELLIGENCE_KEY': request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + 'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, + 'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL, + 'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY, # MinerU settings - "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, - "MINERU_API_URL": request.app.state.config.MINERU_API_URL, - "MINERU_API_KEY": request.app.state.config.MINERU_API_KEY, - "MINERU_API_TIMEOUT": request.app.state.config.MINERU_API_TIMEOUT, - "MINERU_PARAMS": request.app.state.config.MINERU_PARAMS, + 'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE, + 'MINERU_API_URL': request.app.state.config.MINERU_API_URL, + 'MINERU_API_KEY': request.app.state.config.MINERU_API_KEY, + 'MINERU_API_TIMEOUT': request.app.state.config.MINERU_API_TIMEOUT, + 'MINERU_PARAMS': request.app.state.config.MINERU_PARAMS, # Reranking settings - "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, - "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, - "RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL, - "RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, - "RAG_EXTERNAL_RERANKER_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, + 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, + 'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE, + 'RAG_EXTERNAL_RERANKER_URL': request.app.state.config.RAG_EXTERNAL_RERANKER_URL, + 'RAG_EXTERNAL_RERANKER_API_KEY': request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, + 'RAG_EXTERNAL_RERANKER_TIMEOUT': request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, # Chunking settings - "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, - "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER": request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, - "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, - "CHUNK_MIN_SIZE_TARGET": request.app.state.config.CHUNK_MIN_SIZE_TARGET, - "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, + 'TEXT_SPLITTER': request.app.state.config.TEXT_SPLITTER, + 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER': request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, + 'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE, + 'CHUNK_MIN_SIZE_TARGET': request.app.state.config.CHUNK_MIN_SIZE_TARGET, + 'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP, # File upload settings - "FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE, - "FILE_MAX_COUNT": request.app.state.config.FILE_MAX_COUNT, - "FILE_IMAGE_COMPRESSION_WIDTH": request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, - "FILE_IMAGE_COMPRESSION_HEIGHT": request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, - "ALLOWED_FILE_EXTENSIONS": request.app.state.config.ALLOWED_FILE_EXTENSIONS, + 'FILE_MAX_SIZE': request.app.state.config.FILE_MAX_SIZE, + 'FILE_MAX_COUNT': request.app.state.config.FILE_MAX_COUNT, + 'FILE_IMAGE_COMPRESSION_WIDTH': request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, + 'FILE_IMAGE_COMPRESSION_HEIGHT': request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, + 'ALLOWED_FILE_EXTENSIONS': request.app.state.config.ALLOWED_FILE_EXTENSIONS, # Integration settings - "ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, - "ENABLE_ONEDRIVE_INTEGRATION": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + 'ENABLE_GOOGLE_DRIVE_INTEGRATION': request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + 'ENABLE_ONEDRIVE_INTEGRATION': request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, # Web search settings - "web": { - "ENABLE_WEB_SEARCH": request.app.state.config.ENABLE_WEB_SEARCH, - "WEB_SEARCH_ENGINE": request.app.state.config.WEB_SEARCH_ENGINE, - "WEB_SEARCH_TRUST_ENV": request.app.state.config.WEB_SEARCH_TRUST_ENV, - "WEB_SEARCH_RESULT_COUNT": request.app.state.config.WEB_SEARCH_RESULT_COUNT, - "WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, - "WEB_LOADER_CONCURRENT_REQUESTS": request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, - "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, - "BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, - "OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, - "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, - "SEARXNG_LANGUAGE": request.app.state.config.SEARXNG_LANGUAGE, - "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, - "YACY_USERNAME": request.app.state.config.YACY_USERNAME, - "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, - "GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY, - "GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID, - "BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY, - "KAGI_SEARCH_API_KEY": request.app.state.config.KAGI_SEARCH_API_KEY, - "MOJEEK_SEARCH_API_KEY": request.app.state.config.MOJEEK_SEARCH_API_KEY, - "BOCHA_SEARCH_API_KEY": request.app.state.config.BOCHA_SEARCH_API_KEY, - "SERPSTACK_API_KEY": request.app.state.config.SERPSTACK_API_KEY, - "SERPSTACK_HTTPS": request.app.state.config.SERPSTACK_HTTPS, - "SERPER_API_KEY": request.app.state.config.SERPER_API_KEY, - "SERPLY_API_KEY": request.app.state.config.SERPLY_API_KEY, - "DDGS_BACKEND": request.app.state.config.DDGS_BACKEND, - "TAVILY_API_KEY": request.app.state.config.TAVILY_API_KEY, - "SEARCHAPI_API_KEY": request.app.state.config.SEARCHAPI_API_KEY, - "SEARCHAPI_ENGINE": request.app.state.config.SEARCHAPI_ENGINE, - "SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY, - "SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE, - "JINA_API_KEY": request.app.state.config.JINA_API_KEY, - "JINA_API_BASE_URL": request.app.state.config.JINA_API_BASE_URL, - "BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT, - "BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, - "EXA_API_KEY": request.app.state.config.EXA_API_KEY, - "PERPLEXITY_API_KEY": request.app.state.config.PERPLEXITY_API_KEY, - "PERPLEXITY_MODEL": request.app.state.config.PERPLEXITY_MODEL, - "PERPLEXITY_SEARCH_CONTEXT_USAGE": request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE, - "PERPLEXITY_SEARCH_API_URL": request.app.state.config.PERPLEXITY_SEARCH_API_URL, - "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, - "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, - "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, - "WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT, - "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, - "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, - "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, - "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, - "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, - "FIRECRAWL_TIMEOUT": request.app.state.config.FIRECRAWL_TIMEOUT, - "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, - "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, - "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, - "EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL, - "EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, - "YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE, - "YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, - "YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION, - "YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL, - "YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, - "YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, - "YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY, + 'web': { + 'ENABLE_WEB_SEARCH': request.app.state.config.ENABLE_WEB_SEARCH, + 'WEB_SEARCH_ENGINE': request.app.state.config.WEB_SEARCH_ENGINE, + 'WEB_SEARCH_TRUST_ENV': request.app.state.config.WEB_SEARCH_TRUST_ENV, + 'WEB_SEARCH_RESULT_COUNT': request.app.state.config.WEB_SEARCH_RESULT_COUNT, + 'WEB_SEARCH_CONCURRENT_REQUESTS': request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, + 'WEB_FETCH_MAX_CONTENT_LENGTH': request.app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH, + 'WEB_LOADER_CONCURRENT_REQUESTS': request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, + 'WEB_SEARCH_DOMAIN_FILTER_LIST': request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + 'BYPASS_WEB_SEARCH_WEB_LOADER': request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, + 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, + 'SEARXNG_QUERY_URL': request.app.state.config.SEARXNG_QUERY_URL, + 'SEARXNG_LANGUAGE': request.app.state.config.SEARXNG_LANGUAGE, + 'YACY_QUERY_URL': request.app.state.config.YACY_QUERY_URL, + 'YACY_USERNAME': request.app.state.config.YACY_USERNAME, + 'YACY_PASSWORD': request.app.state.config.YACY_PASSWORD, + 'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY, + 'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID, + 'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY, + 'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY, + 'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY, + 'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY, + 'SERPSTACK_API_KEY': request.app.state.config.SERPSTACK_API_KEY, + 'SERPSTACK_HTTPS': request.app.state.config.SERPSTACK_HTTPS, + 'SERPER_API_KEY': request.app.state.config.SERPER_API_KEY, + 'SERPLY_API_KEY': request.app.state.config.SERPLY_API_KEY, + 'DDGS_BACKEND': request.app.state.config.DDGS_BACKEND, + 'TAVILY_API_KEY': request.app.state.config.TAVILY_API_KEY, + 'SEARCHAPI_API_KEY': request.app.state.config.SEARCHAPI_API_KEY, + 'SEARCHAPI_ENGINE': request.app.state.config.SEARCHAPI_ENGINE, + 'SERPAPI_API_KEY': request.app.state.config.SERPAPI_API_KEY, + 'SERPAPI_ENGINE': request.app.state.config.SERPAPI_ENGINE, + 'JINA_API_KEY': request.app.state.config.JINA_API_KEY, + 'JINA_API_BASE_URL': request.app.state.config.JINA_API_BASE_URL, + 'BING_SEARCH_V7_ENDPOINT': request.app.state.config.BING_SEARCH_V7_ENDPOINT, + 'BING_SEARCH_V7_SUBSCRIPTION_KEY': request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + 'EXA_API_KEY': request.app.state.config.EXA_API_KEY, + 'PERPLEXITY_API_KEY': request.app.state.config.PERPLEXITY_API_KEY, + 'PERPLEXITY_MODEL': request.app.state.config.PERPLEXITY_MODEL, + 'PERPLEXITY_SEARCH_CONTEXT_USAGE': request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE, + 'PERPLEXITY_SEARCH_API_URL': request.app.state.config.PERPLEXITY_SEARCH_API_URL, + 'SOUGOU_API_SID': request.app.state.config.SOUGOU_API_SID, + 'SOUGOU_API_SK': request.app.state.config.SOUGOU_API_SK, + 'WEB_LOADER_ENGINE': request.app.state.config.WEB_LOADER_ENGINE, + 'WEB_LOADER_TIMEOUT': request.app.state.config.WEB_LOADER_TIMEOUT, + 'ENABLE_WEB_LOADER_SSL_VERIFICATION': request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + 'PLAYWRIGHT_WS_URL': request.app.state.config.PLAYWRIGHT_WS_URL, + 'PLAYWRIGHT_TIMEOUT': request.app.state.config.PLAYWRIGHT_TIMEOUT, + 'FIRECRAWL_API_KEY': request.app.state.config.FIRECRAWL_API_KEY, + 'FIRECRAWL_API_BASE_URL': request.app.state.config.FIRECRAWL_API_BASE_URL, + 'FIRECRAWL_TIMEOUT': request.app.state.config.FIRECRAWL_TIMEOUT, + 'TAVILY_EXTRACT_DEPTH': request.app.state.config.TAVILY_EXTRACT_DEPTH, + 'EXTERNAL_WEB_SEARCH_URL': request.app.state.config.EXTERNAL_WEB_SEARCH_URL, + 'EXTERNAL_WEB_SEARCH_API_KEY': request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + 'EXTERNAL_WEB_LOADER_URL': request.app.state.config.EXTERNAL_WEB_LOADER_URL, + 'EXTERNAL_WEB_LOADER_API_KEY': request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, + 'YOUTUBE_LOADER_LANGUAGE': request.app.state.config.YOUTUBE_LOADER_LANGUAGE, + 'YOUTUBE_LOADER_PROXY_URL': request.app.state.config.YOUTUBE_LOADER_PROXY_URL, + 'YOUTUBE_LOADER_TRANSLATION': request.app.state.YOUTUBE_LOADER_TRANSLATION, + 'YANDEX_WEB_SEARCH_URL': request.app.state.config.YANDEX_WEB_SEARCH_URL, + 'YANDEX_WEB_SEARCH_API_KEY': request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, + 'YANDEX_WEB_SEARCH_CONFIG': request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, + 'YOUCOM_API_KEY': request.app.state.config.YOUCOM_API_KEY, }, } @@ -604,6 +579,7 @@ class WebConfig(BaseModel): WEB_SEARCH_TRUST_ENV: Optional[bool] = None WEB_SEARCH_RESULT_COUNT: Optional[int] = None WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None + WEB_FETCH_MAX_CONTENT_LENGTH: Optional[int] = None WEB_LOADER_CONCURRENT_REQUESTS: Optional[int] = None WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = [] BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None @@ -743,21 +719,13 @@ class ConfigForm(BaseModel): web: Optional[WebConfig] = None -@router.post("/config/update") -async def update_rag_config( - request: Request, form_data: ConfigForm, user=Depends(get_admin_user) -): +@router.post('/config/update') +async def update_rag_config(request: Request, form_data: ConfigForm, user=Depends(get_admin_user)): # RAG settings request.app.state.config.RAG_TEMPLATE = ( - form_data.RAG_TEMPLATE - if form_data.RAG_TEMPLATE is not None - else request.app.state.config.RAG_TEMPLATE - ) - request.app.state.config.TOP_K = ( - form_data.TOP_K - if form_data.TOP_K is not None - else request.app.state.config.TOP_K + form_data.RAG_TEMPLATE if form_data.RAG_TEMPLATE is not None else request.app.state.config.RAG_TEMPLATE ) + request.app.state.config.TOP_K = form_data.TOP_K if form_data.TOP_K is not None else request.app.state.config.TOP_K request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL = ( form_data.BYPASS_EMBEDDING_AND_RETRIEVAL if form_data.BYPASS_EMBEDDING_AND_RETRIEVAL is not None @@ -782,9 +750,7 @@ async def update_rag_config( ) request.app.state.config.TOP_K_RERANKER = ( - form_data.TOP_K_RERANKER - if form_data.TOP_K_RERANKER is not None - else request.app.state.config.TOP_K_RERANKER + form_data.TOP_K_RERANKER if form_data.TOP_K_RERANKER is not None else request.app.state.config.TOP_K_RERANKER ) request.app.state.config.RELEVANCE_THRESHOLD = ( form_data.RELEVANCE_THRESHOLD @@ -809,9 +775,7 @@ async def update_rag_config( else request.app.state.config.PDF_EXTRACT_IMAGES ) request.app.state.config.PDF_LOADER_MODE = ( - form_data.PDF_LOADER_MODE - if form_data.PDF_LOADER_MODE is not None - else request.app.state.config.PDF_LOADER_MODE + form_data.PDF_LOADER_MODE if form_data.PDF_LOADER_MODE is not None else request.app.state.config.PDF_LOADER_MODE ) request.app.state.config.DATALAB_MARKER_API_KEY = ( form_data.DATALAB_MARKER_API_KEY @@ -879,9 +843,7 @@ async def update_rag_config( else request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY ) request.app.state.config.TIKA_SERVER_URL = ( - form_data.TIKA_SERVER_URL - if form_data.TIKA_SERVER_URL is not None - else request.app.state.config.TIKA_SERVER_URL + form_data.TIKA_SERVER_URL if form_data.TIKA_SERVER_URL is not None else request.app.state.config.TIKA_SERVER_URL ) request.app.state.config.DOCLING_SERVER_URL = ( form_data.DOCLING_SERVER_URL @@ -889,14 +851,10 @@ async def update_rag_config( else request.app.state.config.DOCLING_SERVER_URL ) request.app.state.config.DOCLING_API_KEY = ( - form_data.DOCLING_API_KEY - if form_data.DOCLING_API_KEY is not None - else request.app.state.config.DOCLING_API_KEY + form_data.DOCLING_API_KEY if form_data.DOCLING_API_KEY is not None else request.app.state.config.DOCLING_API_KEY ) request.app.state.config.DOCLING_PARAMS = ( - form_data.DOCLING_PARAMS - if form_data.DOCLING_PARAMS is not None - else request.app.state.config.DOCLING_PARAMS + form_data.DOCLING_PARAMS if form_data.DOCLING_PARAMS is not None else request.app.state.config.DOCLING_PARAMS ) request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = ( form_data.DOCUMENT_INTELLIGENCE_ENDPOINT @@ -927,19 +885,13 @@ async def update_rag_config( # MinerU settings request.app.state.config.MINERU_API_MODE = ( - form_data.MINERU_API_MODE - if form_data.MINERU_API_MODE is not None - else request.app.state.config.MINERU_API_MODE + form_data.MINERU_API_MODE if form_data.MINERU_API_MODE is not None else request.app.state.config.MINERU_API_MODE ) request.app.state.config.MINERU_API_URL = ( - form_data.MINERU_API_URL - if form_data.MINERU_API_URL is not None - else request.app.state.config.MINERU_API_URL + form_data.MINERU_API_URL if form_data.MINERU_API_URL is not None else request.app.state.config.MINERU_API_URL ) request.app.state.config.MINERU_API_KEY = ( - form_data.MINERU_API_KEY - if form_data.MINERU_API_KEY is not None - else request.app.state.config.MINERU_API_KEY + form_data.MINERU_API_KEY if form_data.MINERU_API_KEY is not None else request.app.state.config.MINERU_API_KEY ) request.app.state.config.MINERU_API_TIMEOUT = ( form_data.MINERU_API_TIMEOUT @@ -947,20 +899,18 @@ async def update_rag_config( else request.app.state.config.MINERU_API_TIMEOUT ) request.app.state.config.MINERU_PARAMS = ( - form_data.MINERU_PARAMS - if form_data.MINERU_PARAMS is not None - else request.app.state.config.MINERU_PARAMS + form_data.MINERU_PARAMS if form_data.MINERU_PARAMS is not None else request.app.state.config.MINERU_PARAMS ) # Reranking settings - if request.app.state.config.RAG_RERANKING_ENGINE == "": + if request.app.state.config.RAG_RERANKING_ENGINE == '': # Unloading the internal reranker and clear VRAM memory request.app.state.rf = None request.app.state.RERANKING_FUNCTION = None import gc gc.collect() - if DEVICE_TYPE == "cuda": + if DEVICE_TYPE == 'cuda': import torch if torch.cuda.is_available(): @@ -990,7 +940,7 @@ async def update_rag_config( ) log.info( - f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}" + f'Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}' ) try: request.app.state.config.RAG_RERANKING_MODEL = ( @@ -1018,10 +968,10 @@ async def update_rag_config( request.app.state.rf, ) except Exception as e: - log.error(f"Error loading reranking model: {e}") + log.error(f'Error loading reranking model: {e}') request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False except Exception as e: - log.exception(f"Problem updating reranking model: {e}") + log.exception(f'Problem updating reranking model: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT(e), @@ -1029,9 +979,7 @@ async def update_rag_config( # Chunking settings request.app.state.config.TEXT_SPLITTER = ( - form_data.TEXT_SPLITTER - if form_data.TEXT_SPLITTER is not None - else request.app.state.config.TEXT_SPLITTER + form_data.TEXT_SPLITTER if form_data.TEXT_SPLITTER is not None else request.app.state.config.TEXT_SPLITTER ) request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER = ( form_data.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER @@ -1039,9 +987,7 @@ async def update_rag_config( else request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER ) request.app.state.config.CHUNK_SIZE = ( - form_data.CHUNK_SIZE - if form_data.CHUNK_SIZE is not None - else request.app.state.config.CHUNK_SIZE + form_data.CHUNK_SIZE if form_data.CHUNK_SIZE is not None else request.app.state.config.CHUNK_SIZE ) request.app.state.config.CHUNK_MIN_SIZE_TARGET = ( form_data.CHUNK_MIN_SIZE_TARGET @@ -1049,33 +995,23 @@ async def update_rag_config( else request.app.state.config.CHUNK_MIN_SIZE_TARGET ) request.app.state.config.CHUNK_OVERLAP = ( - form_data.CHUNK_OVERLAP - if form_data.CHUNK_OVERLAP is not None - else request.app.state.config.CHUNK_OVERLAP + form_data.CHUNK_OVERLAP if form_data.CHUNK_OVERLAP is not None else request.app.state.config.CHUNK_OVERLAP ) # File upload settings # Empty string means "clear to None" (unlimited/no compression), # None means "don't change", int means "set to this value" if form_data.FILE_MAX_SIZE is not None: - request.app.state.config.FILE_MAX_SIZE = ( - None if form_data.FILE_MAX_SIZE == "" else form_data.FILE_MAX_SIZE - ) + request.app.state.config.FILE_MAX_SIZE = None if form_data.FILE_MAX_SIZE == '' else form_data.FILE_MAX_SIZE if form_data.FILE_MAX_COUNT is not None: - request.app.state.config.FILE_MAX_COUNT = ( - None if form_data.FILE_MAX_COUNT == "" else form_data.FILE_MAX_COUNT - ) + request.app.state.config.FILE_MAX_COUNT = None if form_data.FILE_MAX_COUNT == '' else form_data.FILE_MAX_COUNT if form_data.FILE_IMAGE_COMPRESSION_WIDTH is not None: request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH = ( - None - if form_data.FILE_IMAGE_COMPRESSION_WIDTH == "" - else form_data.FILE_IMAGE_COMPRESSION_WIDTH + None if form_data.FILE_IMAGE_COMPRESSION_WIDTH == '' else form_data.FILE_IMAGE_COMPRESSION_WIDTH ) if form_data.FILE_IMAGE_COMPRESSION_HEIGHT is not None: request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT = ( - None - if form_data.FILE_IMAGE_COMPRESSION_HEIGHT == "" - else form_data.FILE_IMAGE_COMPRESSION_HEIGHT + None if form_data.FILE_IMAGE_COMPRESSION_HEIGHT == '' else form_data.FILE_IMAGE_COMPRESSION_HEIGHT ) request.app.state.config.ALLOWED_FILE_EXTENSIONS = ( @@ -1100,49 +1036,28 @@ async def update_rag_config( # Web search settings request.app.state.config.ENABLE_WEB_SEARCH = form_data.web.ENABLE_WEB_SEARCH request.app.state.config.WEB_SEARCH_ENGINE = form_data.web.WEB_SEARCH_ENGINE - request.app.state.config.WEB_SEARCH_TRUST_ENV = ( - form_data.web.WEB_SEARCH_TRUST_ENV - ) - request.app.state.config.WEB_SEARCH_RESULT_COUNT = ( - form_data.web.WEB_SEARCH_RESULT_COUNT - ) - request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = ( - form_data.web.WEB_SEARCH_CONCURRENT_REQUESTS - ) - request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = ( - form_data.web.WEB_LOADER_CONCURRENT_REQUESTS - ) - request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = ( - form_data.web.WEB_SEARCH_DOMAIN_FILTER_LIST - ) + request.app.state.config.WEB_SEARCH_TRUST_ENV = form_data.web.WEB_SEARCH_TRUST_ENV + request.app.state.config.WEB_SEARCH_RESULT_COUNT = form_data.web.WEB_SEARCH_RESULT_COUNT + request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = form_data.web.WEB_SEARCH_CONCURRENT_REQUESTS + request.app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH = form_data.web.WEB_FETCH_MAX_CONTENT_LENGTH + request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = form_data.web.WEB_LOADER_CONCURRENT_REQUESTS + request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST = form_data.web.WEB_SEARCH_DOMAIN_FILTER_LIST request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = ( form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL ) - request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = ( - form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER - ) - request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = ( - form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY - ) + request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER = form_data.web.BYPASS_WEB_SEARCH_WEB_LOADER + request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY = form_data.web.OLLAMA_CLOUD_WEB_SEARCH_API_KEY request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL request.app.state.config.SEARXNG_LANGUAGE = form_data.web.SEARXNG_LANGUAGE request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY - request.app.state.config.GOOGLE_PSE_ENGINE_ID = ( - form_data.web.GOOGLE_PSE_ENGINE_ID - ) - request.app.state.config.BRAVE_SEARCH_API_KEY = ( - form_data.web.BRAVE_SEARCH_API_KEY - ) + request.app.state.config.GOOGLE_PSE_ENGINE_ID = form_data.web.GOOGLE_PSE_ENGINE_ID + request.app.state.config.BRAVE_SEARCH_API_KEY = form_data.web.BRAVE_SEARCH_API_KEY request.app.state.config.KAGI_SEARCH_API_KEY = form_data.web.KAGI_SEARCH_API_KEY - request.app.state.config.MOJEEK_SEARCH_API_KEY = ( - form_data.web.MOJEEK_SEARCH_API_KEY - ) - request.app.state.config.BOCHA_SEARCH_API_KEY = ( - form_data.web.BOCHA_SEARCH_API_KEY - ) + request.app.state.config.MOJEEK_SEARCH_API_KEY = form_data.web.MOJEEK_SEARCH_API_KEY + request.app.state.config.BOCHA_SEARCH_API_KEY = form_data.web.BOCHA_SEARCH_API_KEY request.app.state.config.SERPSTACK_API_KEY = form_data.web.SERPSTACK_API_KEY request.app.state.config.SERPSTACK_HTTPS = form_data.web.SERPSTACK_HTTPS request.app.state.config.SERPER_API_KEY = form_data.web.SERPER_API_KEY @@ -1155,21 +1070,13 @@ async def update_rag_config( request.app.state.config.SERPAPI_ENGINE = form_data.web.SERPAPI_ENGINE request.app.state.config.JINA_API_KEY = form_data.web.JINA_API_KEY request.app.state.config.JINA_API_BASE_URL = form_data.web.JINA_API_BASE_URL - request.app.state.config.BING_SEARCH_V7_ENDPOINT = ( - form_data.web.BING_SEARCH_V7_ENDPOINT - ) - request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = ( - form_data.web.BING_SEARCH_V7_SUBSCRIPTION_KEY - ) + request.app.state.config.BING_SEARCH_V7_ENDPOINT = form_data.web.BING_SEARCH_V7_ENDPOINT + request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY = form_data.web.BING_SEARCH_V7_SUBSCRIPTION_KEY request.app.state.config.EXA_API_KEY = form_data.web.EXA_API_KEY request.app.state.config.PERPLEXITY_API_KEY = form_data.web.PERPLEXITY_API_KEY request.app.state.config.PERPLEXITY_MODEL = form_data.web.PERPLEXITY_MODEL - request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE = ( - form_data.web.PERPLEXITY_SEARCH_CONTEXT_USAGE - ) - request.app.state.config.PERPLEXITY_SEARCH_API_URL = ( - form_data.web.PERPLEXITY_SEARCH_API_URL - ) + request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE = form_data.web.PERPLEXITY_SEARCH_CONTEXT_USAGE + request.app.state.config.PERPLEXITY_SEARCH_API_URL = form_data.web.PERPLEXITY_SEARCH_API_URL request.app.state.config.SOUGOU_API_SID = form_data.web.SOUGOU_API_SID request.app.state.config.SOUGOU_API_SK = form_data.web.SOUGOU_API_SK @@ -1177,178 +1084,153 @@ async def update_rag_config( request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE request.app.state.config.WEB_LOADER_TIMEOUT = form_data.web.WEB_LOADER_TIMEOUT - request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ( - form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION - ) + request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION request.app.state.config.PLAYWRIGHT_WS_URL = form_data.web.PLAYWRIGHT_WS_URL request.app.state.config.PLAYWRIGHT_TIMEOUT = form_data.web.PLAYWRIGHT_TIMEOUT request.app.state.config.FIRECRAWL_API_KEY = form_data.web.FIRECRAWL_API_KEY - request.app.state.config.FIRECRAWL_API_BASE_URL = ( - form_data.web.FIRECRAWL_API_BASE_URL - ) + request.app.state.config.FIRECRAWL_API_BASE_URL = form_data.web.FIRECRAWL_API_BASE_URL request.app.state.config.FIRECRAWL_TIMEOUT = form_data.web.FIRECRAWL_TIMEOUT - request.app.state.config.EXTERNAL_WEB_SEARCH_URL = ( - form_data.web.EXTERNAL_WEB_SEARCH_URL - ) - request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = ( - form_data.web.EXTERNAL_WEB_SEARCH_API_KEY - ) - request.app.state.config.EXTERNAL_WEB_LOADER_URL = ( - form_data.web.EXTERNAL_WEB_LOADER_URL - ) - request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY = ( - form_data.web.EXTERNAL_WEB_LOADER_API_KEY - ) - request.app.state.config.TAVILY_EXTRACT_DEPTH = ( - form_data.web.TAVILY_EXTRACT_DEPTH - ) - request.app.state.config.YOUTUBE_LOADER_LANGUAGE = ( - form_data.web.YOUTUBE_LOADER_LANGUAGE - ) - request.app.state.config.YOUTUBE_LOADER_PROXY_URL = ( - form_data.web.YOUTUBE_LOADER_PROXY_URL - ) - request.app.state.YOUTUBE_LOADER_TRANSLATION = ( - form_data.web.YOUTUBE_LOADER_TRANSLATION - ) - request.app.state.config.YANDEX_WEB_SEARCH_URL = ( - form_data.web.YANDEX_WEB_SEARCH_URL - ) - request.app.state.config.YANDEX_WEB_SEARCH_API_KEY = ( - form_data.web.YANDEX_WEB_SEARCH_API_KEY - ) - request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = ( - form_data.web.YANDEX_WEB_SEARCH_CONFIG - ) + request.app.state.config.EXTERNAL_WEB_SEARCH_URL = form_data.web.EXTERNAL_WEB_SEARCH_URL + request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = form_data.web.EXTERNAL_WEB_SEARCH_API_KEY + request.app.state.config.EXTERNAL_WEB_LOADER_URL = form_data.web.EXTERNAL_WEB_LOADER_URL + request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY = form_data.web.EXTERNAL_WEB_LOADER_API_KEY + request.app.state.config.TAVILY_EXTRACT_DEPTH = form_data.web.TAVILY_EXTRACT_DEPTH + request.app.state.config.YOUTUBE_LOADER_LANGUAGE = form_data.web.YOUTUBE_LOADER_LANGUAGE + request.app.state.config.YOUTUBE_LOADER_PROXY_URL = form_data.web.YOUTUBE_LOADER_PROXY_URL + request.app.state.YOUTUBE_LOADER_TRANSLATION = form_data.web.YOUTUBE_LOADER_TRANSLATION + request.app.state.config.YANDEX_WEB_SEARCH_URL = form_data.web.YANDEX_WEB_SEARCH_URL + request.app.state.config.YANDEX_WEB_SEARCH_API_KEY = form_data.web.YANDEX_WEB_SEARCH_API_KEY + request.app.state.config.YANDEX_WEB_SEARCH_CONFIG = form_data.web.YANDEX_WEB_SEARCH_CONFIG request.app.state.config.YOUCOM_API_KEY = form_data.web.YOUCOM_API_KEY return { - "status": True, + 'status': True, # RAG settings - "RAG_TEMPLATE": request.app.state.config.RAG_TEMPLATE, - "TOP_K": request.app.state.config.TOP_K, - "BYPASS_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, - "RAG_FULL_CONTEXT": request.app.state.config.RAG_FULL_CONTEXT, + 'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE, + 'TOP_K': request.app.state.config.TOP_K, + 'BYPASS_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL, + 'RAG_FULL_CONTEXT': request.app.state.config.RAG_FULL_CONTEXT, # Hybrid search settings - "ENABLE_RAG_HYBRID_SEARCH": request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, - "TOP_K_RERANKER": request.app.state.config.TOP_K_RERANKER, - "RELEVANCE_THRESHOLD": request.app.state.config.RELEVANCE_THRESHOLD, - "HYBRID_BM25_WEIGHT": request.app.state.config.HYBRID_BM25_WEIGHT, + 'ENABLE_RAG_HYBRID_SEARCH': request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, + 'TOP_K_RERANKER': request.app.state.config.TOP_K_RERANKER, + 'RELEVANCE_THRESHOLD': request.app.state.config.RELEVANCE_THRESHOLD, + 'HYBRID_BM25_WEIGHT': request.app.state.config.HYBRID_BM25_WEIGHT, # Content extraction settings - "CONTENT_EXTRACTION_ENGINE": request.app.state.config.CONTENT_EXTRACTION_ENGINE, - "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES, - "PDF_LOADER_MODE": request.app.state.config.PDF_LOADER_MODE, - "DATALAB_MARKER_API_KEY": request.app.state.config.DATALAB_MARKER_API_KEY, - "DATALAB_MARKER_API_BASE_URL": request.app.state.config.DATALAB_MARKER_API_BASE_URL, - "DATALAB_MARKER_ADDITIONAL_CONFIG": request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, - "DATALAB_MARKER_SKIP_CACHE": request.app.state.config.DATALAB_MARKER_SKIP_CACHE, - "DATALAB_MARKER_FORCE_OCR": request.app.state.config.DATALAB_MARKER_FORCE_OCR, - "DATALAB_MARKER_PAGINATE": request.app.state.config.DATALAB_MARKER_PAGINATE, - "DATALAB_MARKER_STRIP_EXISTING_OCR": request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, - "DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION": request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, - "DATALAB_MARKER_USE_LLM": request.app.state.config.DATALAB_MARKER_USE_LLM, - "DATALAB_MARKER_OUTPUT_FORMAT": request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, - "EXTERNAL_DOCUMENT_LOADER_URL": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, - "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, - "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL, - "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL, - "DOCLING_API_KEY": request.app.state.config.DOCLING_API_KEY, - "DOCLING_PARAMS": request.app.state.config.DOCLING_PARAMS, - "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, - "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, - "DOCUMENT_INTELLIGENCE_MODEL": request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, - "MISTRAL_OCR_API_BASE_URL": request.app.state.config.MISTRAL_OCR_API_BASE_URL, - "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY, + 'CONTENT_EXTRACTION_ENGINE': request.app.state.config.CONTENT_EXTRACTION_ENGINE, + 'PDF_EXTRACT_IMAGES': request.app.state.config.PDF_EXTRACT_IMAGES, + 'PDF_LOADER_MODE': request.app.state.config.PDF_LOADER_MODE, + 'DATALAB_MARKER_API_KEY': request.app.state.config.DATALAB_MARKER_API_KEY, + 'DATALAB_MARKER_API_BASE_URL': request.app.state.config.DATALAB_MARKER_API_BASE_URL, + 'DATALAB_MARKER_ADDITIONAL_CONFIG': request.app.state.config.DATALAB_MARKER_ADDITIONAL_CONFIG, + 'DATALAB_MARKER_SKIP_CACHE': request.app.state.config.DATALAB_MARKER_SKIP_CACHE, + 'DATALAB_MARKER_FORCE_OCR': request.app.state.config.DATALAB_MARKER_FORCE_OCR, + 'DATALAB_MARKER_PAGINATE': request.app.state.config.DATALAB_MARKER_PAGINATE, + 'DATALAB_MARKER_STRIP_EXISTING_OCR': request.app.state.config.DATALAB_MARKER_STRIP_EXISTING_OCR, + 'DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION': request.app.state.config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION, + 'DATALAB_MARKER_USE_LLM': request.app.state.config.DATALAB_MARKER_USE_LLM, + 'DATALAB_MARKER_OUTPUT_FORMAT': request.app.state.config.DATALAB_MARKER_OUTPUT_FORMAT, + 'EXTERNAL_DOCUMENT_LOADER_URL': request.app.state.config.EXTERNAL_DOCUMENT_LOADER_URL, + 'EXTERNAL_DOCUMENT_LOADER_API_KEY': request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY, + 'TIKA_SERVER_URL': request.app.state.config.TIKA_SERVER_URL, + 'DOCLING_SERVER_URL': request.app.state.config.DOCLING_SERVER_URL, + 'DOCLING_API_KEY': request.app.state.config.DOCLING_API_KEY, + 'DOCLING_PARAMS': request.app.state.config.DOCLING_PARAMS, + 'DOCUMENT_INTELLIGENCE_ENDPOINT': request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT, + 'DOCUMENT_INTELLIGENCE_KEY': request.app.state.config.DOCUMENT_INTELLIGENCE_KEY, + 'DOCUMENT_INTELLIGENCE_MODEL': request.app.state.config.DOCUMENT_INTELLIGENCE_MODEL, + 'MISTRAL_OCR_API_BASE_URL': request.app.state.config.MISTRAL_OCR_API_BASE_URL, + 'MISTRAL_OCR_API_KEY': request.app.state.config.MISTRAL_OCR_API_KEY, # MinerU settings - "MINERU_API_MODE": request.app.state.config.MINERU_API_MODE, - "MINERU_API_URL": request.app.state.config.MINERU_API_URL, - "MINERU_API_KEY": request.app.state.config.MINERU_API_KEY, - "MINERU_API_TIMEOUT": request.app.state.config.MINERU_API_TIMEOUT, - "MINERU_PARAMS": request.app.state.config.MINERU_PARAMS, + 'MINERU_API_MODE': request.app.state.config.MINERU_API_MODE, + 'MINERU_API_URL': request.app.state.config.MINERU_API_URL, + 'MINERU_API_KEY': request.app.state.config.MINERU_API_KEY, + 'MINERU_API_TIMEOUT': request.app.state.config.MINERU_API_TIMEOUT, + 'MINERU_PARAMS': request.app.state.config.MINERU_PARAMS, # Reranking settings - "RAG_RERANKING_MODEL": request.app.state.config.RAG_RERANKING_MODEL, - "RAG_RERANKING_ENGINE": request.app.state.config.RAG_RERANKING_ENGINE, - "RAG_EXTERNAL_RERANKER_URL": request.app.state.config.RAG_EXTERNAL_RERANKER_URL, - "RAG_EXTERNAL_RERANKER_API_KEY": request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, - "RAG_EXTERNAL_RERANKER_TIMEOUT": request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, + 'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL, + 'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE, + 'RAG_EXTERNAL_RERANKER_URL': request.app.state.config.RAG_EXTERNAL_RERANKER_URL, + 'RAG_EXTERNAL_RERANKER_API_KEY': request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY, + 'RAG_EXTERNAL_RERANKER_TIMEOUT': request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT, # Chunking settings - "TEXT_SPLITTER": request.app.state.config.TEXT_SPLITTER, - "CHUNK_SIZE": request.app.state.config.CHUNK_SIZE, - "CHUNK_MIN_SIZE_TARGET": request.app.state.config.CHUNK_MIN_SIZE_TARGET, - "ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER": request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, - "CHUNK_OVERLAP": request.app.state.config.CHUNK_OVERLAP, + 'TEXT_SPLITTER': request.app.state.config.TEXT_SPLITTER, + 'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE, + 'CHUNK_MIN_SIZE_TARGET': request.app.state.config.CHUNK_MIN_SIZE_TARGET, + 'ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER': request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER, + 'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP, # File upload settings - "FILE_MAX_SIZE": request.app.state.config.FILE_MAX_SIZE, - "FILE_MAX_COUNT": request.app.state.config.FILE_MAX_COUNT, - "FILE_IMAGE_COMPRESSION_WIDTH": request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, - "FILE_IMAGE_COMPRESSION_HEIGHT": request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, - "ALLOWED_FILE_EXTENSIONS": request.app.state.config.ALLOWED_FILE_EXTENSIONS, + 'FILE_MAX_SIZE': request.app.state.config.FILE_MAX_SIZE, + 'FILE_MAX_COUNT': request.app.state.config.FILE_MAX_COUNT, + 'FILE_IMAGE_COMPRESSION_WIDTH': request.app.state.config.FILE_IMAGE_COMPRESSION_WIDTH, + 'FILE_IMAGE_COMPRESSION_HEIGHT': request.app.state.config.FILE_IMAGE_COMPRESSION_HEIGHT, + 'ALLOWED_FILE_EXTENSIONS': request.app.state.config.ALLOWED_FILE_EXTENSIONS, # Integration settings - "ENABLE_GOOGLE_DRIVE_INTEGRATION": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, - "ENABLE_ONEDRIVE_INTEGRATION": request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, + 'ENABLE_GOOGLE_DRIVE_INTEGRATION': request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION, + 'ENABLE_ONEDRIVE_INTEGRATION': request.app.state.config.ENABLE_ONEDRIVE_INTEGRATION, # Web search settings - "web": { - "ENABLE_WEB_SEARCH": request.app.state.config.ENABLE_WEB_SEARCH, - "WEB_SEARCH_ENGINE": request.app.state.config.WEB_SEARCH_ENGINE, - "WEB_SEARCH_TRUST_ENV": request.app.state.config.WEB_SEARCH_TRUST_ENV, - "WEB_SEARCH_RESULT_COUNT": request.app.state.config.WEB_SEARCH_RESULT_COUNT, - "WEB_SEARCH_CONCURRENT_REQUESTS": request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, - "WEB_LOADER_CONCURRENT_REQUESTS": request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, - "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, - "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, - "BYPASS_WEB_SEARCH_WEB_LOADER": request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, - "OLLAMA_CLOUD_WEB_SEARCH_API_KEY": request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, - "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL, - "SEARXNG_LANGUAGE": request.app.state.config.SEARXNG_LANGUAGE, - "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL, - "YACY_USERNAME": request.app.state.config.YACY_USERNAME, - "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD, - "GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY, - "GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID, - "BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY, - "KAGI_SEARCH_API_KEY": request.app.state.config.KAGI_SEARCH_API_KEY, - "MOJEEK_SEARCH_API_KEY": request.app.state.config.MOJEEK_SEARCH_API_KEY, - "BOCHA_SEARCH_API_KEY": request.app.state.config.BOCHA_SEARCH_API_KEY, - "SERPSTACK_API_KEY": request.app.state.config.SERPSTACK_API_KEY, - "SERPSTACK_HTTPS": request.app.state.config.SERPSTACK_HTTPS, - "SERPER_API_KEY": request.app.state.config.SERPER_API_KEY, - "SERPLY_API_KEY": request.app.state.config.SERPLY_API_KEY, - "TAVILY_API_KEY": request.app.state.config.TAVILY_API_KEY, - "SEARCHAPI_API_KEY": request.app.state.config.SEARCHAPI_API_KEY, - "SEARCHAPI_ENGINE": request.app.state.config.SEARCHAPI_ENGINE, - "SERPAPI_API_KEY": request.app.state.config.SERPAPI_API_KEY, - "SERPAPI_ENGINE": request.app.state.config.SERPAPI_ENGINE, - "JINA_API_KEY": request.app.state.config.JINA_API_KEY, - "JINA_API_BASE_URL": request.app.state.config.JINA_API_BASE_URL, - "BING_SEARCH_V7_ENDPOINT": request.app.state.config.BING_SEARCH_V7_ENDPOINT, - "BING_SEARCH_V7_SUBSCRIPTION_KEY": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, - "EXA_API_KEY": request.app.state.config.EXA_API_KEY, - "PERPLEXITY_API_KEY": request.app.state.config.PERPLEXITY_API_KEY, - "PERPLEXITY_MODEL": request.app.state.config.PERPLEXITY_MODEL, - "PERPLEXITY_SEARCH_CONTEXT_USAGE": request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE, - "PERPLEXITY_SEARCH_API_URL": request.app.state.config.PERPLEXITY_SEARCH_API_URL, - "SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID, - "SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK, - "WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE, - "WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT, - "ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, - "PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL, - "PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT, - "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY, - "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL, - "FIRECRAWL_TIMEOUT": request.app.state.config.FIRECRAWL_TIMEOUT, - "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH, - "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL, - "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, - "EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL, - "EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, - "YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE, - "YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL, - "YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION, - "YANDEX_WEB_SEARCH_URL": request.app.state.config.YANDEX_WEB_SEARCH_URL, - "YANDEX_WEB_SEARCH_API_KEY": request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, - "YANDEX_WEB_SEARCH_CONFIG": request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, - "YOUCOM_API_KEY": request.app.state.config.YOUCOM_API_KEY, + 'web': { + 'ENABLE_WEB_SEARCH': request.app.state.config.ENABLE_WEB_SEARCH, + 'WEB_SEARCH_ENGINE': request.app.state.config.WEB_SEARCH_ENGINE, + 'WEB_SEARCH_TRUST_ENV': request.app.state.config.WEB_SEARCH_TRUST_ENV, + 'WEB_SEARCH_RESULT_COUNT': request.app.state.config.WEB_SEARCH_RESULT_COUNT, + 'WEB_SEARCH_CONCURRENT_REQUESTS': request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, + 'FETCH_URL_MAX_CONTENT_LENGTH': request.app.state.config.FETCH_URL_MAX_CONTENT_LENGTH, + 'WEB_LOADER_CONCURRENT_REQUESTS': request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS, + 'WEB_SEARCH_DOMAIN_FILTER_LIST': request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, + 'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL, + 'BYPASS_WEB_SEARCH_WEB_LOADER': request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER, + 'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, + 'SEARXNG_QUERY_URL': request.app.state.config.SEARXNG_QUERY_URL, + 'SEARXNG_LANGUAGE': request.app.state.config.SEARXNG_LANGUAGE, + 'YACY_QUERY_URL': request.app.state.config.YACY_QUERY_URL, + 'YACY_USERNAME': request.app.state.config.YACY_USERNAME, + 'YACY_PASSWORD': request.app.state.config.YACY_PASSWORD, + 'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY, + 'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID, + 'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY, + 'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY, + 'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY, + 'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY, + 'SERPSTACK_API_KEY': request.app.state.config.SERPSTACK_API_KEY, + 'SERPSTACK_HTTPS': request.app.state.config.SERPSTACK_HTTPS, + 'SERPER_API_KEY': request.app.state.config.SERPER_API_KEY, + 'SERPLY_API_KEY': request.app.state.config.SERPLY_API_KEY, + 'TAVILY_API_KEY': request.app.state.config.TAVILY_API_KEY, + 'SEARCHAPI_API_KEY': request.app.state.config.SEARCHAPI_API_KEY, + 'SEARCHAPI_ENGINE': request.app.state.config.SEARCHAPI_ENGINE, + 'SERPAPI_API_KEY': request.app.state.config.SERPAPI_API_KEY, + 'SERPAPI_ENGINE': request.app.state.config.SERPAPI_ENGINE, + 'JINA_API_KEY': request.app.state.config.JINA_API_KEY, + 'JINA_API_BASE_URL': request.app.state.config.JINA_API_BASE_URL, + 'BING_SEARCH_V7_ENDPOINT': request.app.state.config.BING_SEARCH_V7_ENDPOINT, + 'BING_SEARCH_V7_SUBSCRIPTION_KEY': request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, + 'EXA_API_KEY': request.app.state.config.EXA_API_KEY, + 'PERPLEXITY_API_KEY': request.app.state.config.PERPLEXITY_API_KEY, + 'PERPLEXITY_MODEL': request.app.state.config.PERPLEXITY_MODEL, + 'PERPLEXITY_SEARCH_CONTEXT_USAGE': request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE, + 'PERPLEXITY_SEARCH_API_URL': request.app.state.config.PERPLEXITY_SEARCH_API_URL, + 'SOUGOU_API_SID': request.app.state.config.SOUGOU_API_SID, + 'SOUGOU_API_SK': request.app.state.config.SOUGOU_API_SK, + 'WEB_LOADER_ENGINE': request.app.state.config.WEB_LOADER_ENGINE, + 'WEB_LOADER_TIMEOUT': request.app.state.config.WEB_LOADER_TIMEOUT, + 'ENABLE_WEB_LOADER_SSL_VERIFICATION': request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION, + 'PLAYWRIGHT_WS_URL': request.app.state.config.PLAYWRIGHT_WS_URL, + 'PLAYWRIGHT_TIMEOUT': request.app.state.config.PLAYWRIGHT_TIMEOUT, + 'FIRECRAWL_API_KEY': request.app.state.config.FIRECRAWL_API_KEY, + 'FIRECRAWL_API_BASE_URL': request.app.state.config.FIRECRAWL_API_BASE_URL, + 'FIRECRAWL_TIMEOUT': request.app.state.config.FIRECRAWL_TIMEOUT, + 'TAVILY_EXTRACT_DEPTH': request.app.state.config.TAVILY_EXTRACT_DEPTH, + 'EXTERNAL_WEB_SEARCH_URL': request.app.state.config.EXTERNAL_WEB_SEARCH_URL, + 'EXTERNAL_WEB_SEARCH_API_KEY': request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY, + 'EXTERNAL_WEB_LOADER_URL': request.app.state.config.EXTERNAL_WEB_LOADER_URL, + 'EXTERNAL_WEB_LOADER_API_KEY': request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY, + 'YOUTUBE_LOADER_LANGUAGE': request.app.state.config.YOUTUBE_LOADER_LANGUAGE, + 'YOUTUBE_LOADER_PROXY_URL': request.app.state.config.YOUTUBE_LOADER_PROXY_URL, + 'YOUTUBE_LOADER_TRANSLATION': request.app.state.YOUTUBE_LOADER_TRANSLATION, + 'YANDEX_WEB_SEARCH_URL': request.app.state.config.YANDEX_WEB_SEARCH_URL, + 'YANDEX_WEB_SEARCH_API_KEY': request.app.state.config.YANDEX_WEB_SEARCH_API_KEY, + 'YANDEX_WEB_SEARCH_CONFIG': request.app.state.config.YANDEX_WEB_SEARCH_CONFIG, + 'YOUCOM_API_KEY': request.app.state.config.YOUCOM_API_KEY, }, } @@ -1361,11 +1243,11 @@ async def update_rag_config( def can_merge_chunks(a: Document, b: Document) -> bool: - if a.metadata.get("source") != b.metadata.get("source"): + if a.metadata.get('source') != b.metadata.get('source'): return False - a_file_id = a.metadata.get("file_id") - b_file_id = b.metadata.get("file_id") + a_file_id = a.metadata.get('file_id') + b_file_id = b.metadata.get('file_id') if a_file_id is not None and b_file_id is not None: return a_file_id == b_file_id @@ -1391,16 +1273,14 @@ def merge_docs_to_target_size( return chunks measure_chunk_size = len - if request.app.state.config.TEXT_SPLITTER == "token": - encoding = tiktoken.get_encoding( - str(request.app.state.config.TIKTOKEN_ENCODING_NAME) - ) + if request.app.state.config.TEXT_SPLITTER == 'token': + encoding = tiktoken.get_encoding(str(request.app.state.config.TIKTOKEN_ENCODING_NAME)) measure_chunk_size = lambda text: len(encoding.encode(text)) processed_chunks: list[Document] = [] current_chunk: Document | None = None - current_content: str = "" + current_content: str = '' for next_chunk in chunks: if current_chunk is None: @@ -1408,7 +1288,7 @@ def merge_docs_to_target_size( current_content = next_chunk.page_content continue # First chunk initialization - proposed_content = f"{current_content}\n\n{next_chunk.page_content}" + proposed_content = f'{current_content}\n\n{next_chunk.page_content}' can_merge = ( can_merge_chunks(current_chunk, next_chunk) @@ -1454,26 +1334,24 @@ def _get_docs_info(docs: list[Document]) -> str: # Trying to select relevant metadata identifying the document. for doc in docs: - metadata = getattr(doc, "metadata", {}) - doc_name = metadata.get("name", "") + metadata = getattr(doc, 'metadata', {}) + doc_name = metadata.get('name', '') if not doc_name: - doc_name = metadata.get("title", "") + doc_name = metadata.get('title', '') if not doc_name: - doc_name = metadata.get("source", "") + doc_name = metadata.get('source', '') if doc_name: docs_info.add(doc_name) - return ", ".join(docs_info) + return ', '.join(docs_info) - log.debug( - f"save_docs_to_vector_db: document {_get_docs_info(docs)} {collection_name}" - ) + log.debug(f'save_docs_to_vector_db: document {_get_docs_info(docs)} {collection_name}') # Check if entries with the same hash (metadata.hash) already exist - if metadata and "hash" in metadata: + if metadata and 'hash' in metadata: result = VECTOR_DB_CLIENT.query( collection_name=collection_name, - filter={"hash": metadata["hash"]}, + filter={'hash': metadata['hash']}, ) if result is not None and result.ids and len(result.ids) > 0: @@ -1484,24 +1362,24 @@ def _get_docs_info(docs: list[Document]) -> str: # If different file_id, this is a duplicate - block it existing_file_id = None if result.metadatas and result.metadatas[0]: - existing_file_id = result.metadatas[0][0].get("file_id") + existing_file_id = result.metadatas[0][0].get('file_id') - if existing_file_id != metadata.get("file_id"): - log.info(f"Document with hash {metadata['hash']} already exists") + if existing_file_id != metadata.get('file_id'): + log.info(f'Document with hash {metadata["hash"]} already exists') raise ValueError(ERROR_MESSAGES.DUPLICATE_CONTENT) if split: if request.app.state.config.ENABLE_MARKDOWN_HEADER_TEXT_SPLITTER: - log.info("Using markdown header text splitter") + log.info('Using markdown header text splitter') # Define headers to split on - covering most common markdown header levels markdown_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=[ - ("#", "Header 1"), - ("##", "Header 2"), - ("###", "Header 3"), - ("####", "Header 4"), - ("#####", "Header 5"), - ("######", "Header 6"), + ('#', 'Header 1'), + ('##', 'Header 2'), + ('###', 'Header 3'), + ('####', 'Header 4'), + ('#####', 'Header 5'), + ('######', 'Header 6'), ], strip_headers=False, # Keep headers in content for context ) @@ -1514,9 +1392,7 @@ def _get_docs_info(docs: list[Document]) -> str: page_content=split_chunk.page_content, metadata={**doc.metadata}, ) - for split_chunk in markdown_splitter.split_text( - doc.page_content - ) + for split_chunk in markdown_splitter.split_text(doc.page_content) ] ) @@ -1524,17 +1400,15 @@ def _get_docs_info(docs: list[Document]) -> str: if request.app.state.config.CHUNK_MIN_SIZE_TARGET > 0: docs = merge_docs_to_target_size(request, docs) - if request.app.state.config.TEXT_SPLITTER in ["", "character"]: + if request.app.state.config.TEXT_SPLITTER in ['', 'character']: text_splitter = RecursiveCharacterTextSplitter( chunk_size=request.app.state.config.CHUNK_SIZE, chunk_overlap=request.app.state.config.CHUNK_OVERLAP, add_start_index=True, ) docs = text_splitter.split_documents(docs) - elif request.app.state.config.TEXT_SPLITTER == "token": - log.info( - f"Using token text splitter: {request.app.state.config.TIKTOKEN_ENCODING_NAME}" - ) + elif request.app.state.config.TEXT_SPLITTER == 'token': + log.info(f'Using token text splitter: {request.app.state.config.TIKTOKEN_ENCODING_NAME}') tiktoken.get_encoding(str(request.app.state.config.TIKTOKEN_ENCODING_NAME)) text_splitter = TokenTextSplitter( @@ -1545,7 +1419,7 @@ def _get_docs_info(docs: list[Document]) -> str: ) docs = text_splitter.split_documents(docs) else: - raise ValueError(ERROR_MESSAGES.DEFAULT("Invalid text splitter")) + raise ValueError(ERROR_MESSAGES.DEFAULT('Invalid text splitter')) if len(docs) == 0: raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) @@ -1555,9 +1429,9 @@ def _get_docs_info(docs: list[Document]) -> str: { **doc.metadata, **(metadata if metadata else {}), - "embedding_config": { - "engine": request.app.state.config.RAG_EMBEDDING_ENGINE, - "model": request.app.state.config.RAG_EMBEDDING_MODEL, + 'embedding_config': { + 'engine': request.app.state.config.RAG_EMBEDDING_ENGINE, + 'model': request.app.state.config.RAG_EMBEDDING_MODEL, }, } for doc in docs @@ -1565,44 +1439,42 @@ def _get_docs_info(docs: list[Document]) -> str: try: if VECTOR_DB_CLIENT.has_collection(collection_name=collection_name): - log.info(f"collection {collection_name} already exists") + log.info(f'collection {collection_name} already exists') if overwrite: VECTOR_DB_CLIENT.delete_collection(collection_name=collection_name) - log.info(f"deleting existing collection {collection_name}") + log.info(f'deleting existing collection {collection_name}') elif add is False: - log.info( - f"collection {collection_name} already exists, overwrite is False and add is False" - ) + log.info(f'collection {collection_name} already exists, overwrite is False and add is False') return True - log.info(f"generating embeddings for {collection_name}") + log.info(f'generating embeddings for {collection_name}') embedding_function = get_embedding_function( request.app.state.config.RAG_EMBEDDING_ENGINE, request.app.state.config.RAG_EMBEDDING_MODEL, request.app.state.ef, ( request.app.state.config.RAG_OPENAI_API_BASE_URL - if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'openai' else ( request.app.state.config.RAG_OLLAMA_BASE_URL - if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' else request.app.state.config.RAG_AZURE_OPENAI_BASE_URL ) ), ( request.app.state.config.RAG_OPENAI_API_KEY - if request.app.state.config.RAG_EMBEDDING_ENGINE == "openai" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'openai' else ( request.app.state.config.RAG_OLLAMA_API_KEY - if request.app.state.config.RAG_EMBEDDING_ENGINE == "ollama" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'ollama' else request.app.state.config.RAG_AZURE_OPENAI_API_KEY ) ), request.app.state.config.RAG_EMBEDDING_BATCH_SIZE, azure_api_version=( request.app.state.config.RAG_AZURE_OPENAI_API_VERSION - if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai" + if request.app.state.config.RAG_EMBEDDING_ENGINE == 'azure_openai' else None ), enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING, @@ -1615,32 +1487,32 @@ def _get_docs_info(docs: list[Document]) -> str: future = asyncio.run_coroutine_threadsafe( embedding_function( - list(map(lambda x: x.replace("\n", " "), texts)), + list(map(lambda x: x.replace('\n', ' '), texts)), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user, ), request.app.state.main_loop, ) embeddings = future.result(timeout=embedding_timeout) - log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items") + log.info(f'embeddings generated {len(embeddings)} for {len(texts)} items') items = [ { - "id": str(uuid.uuid4()), - "text": text, - "vector": embeddings[idx], - "metadata": metadatas[idx], + 'id': str(uuid.uuid4()), + 'text': text, + 'vector': embeddings[idx], + 'metadata': metadatas[idx], } for idx, text in enumerate(texts) ] - log.info(f"adding to collection {collection_name}") + log.info(f'adding to collection {collection_name}') VECTOR_DB_CLIENT.insert( collection_name=collection_name, items=items, ) - log.info(f"added {len(items)} items to collection {collection_name}") + log.info(f'added {len(items)} items to collection {collection_name}') return True except Exception as e: log.exception(e) @@ -1653,7 +1525,7 @@ class ProcessFileForm(BaseModel): collection_name: Optional[str] = None -@router.post("/process/file") +@router.post('/process/file') def process_file( request: Request, form_data: ProcessFileForm, @@ -1666,18 +1538,17 @@ def process_file( Note: granular session management is used to prevent connection pool exhaustion. The session is committed before external API calls, and updates use a fresh session. """ - if user.role == "admin": + if user.role == 'admin': file = Files.get_file_by_id(form_data.file_id, db=db) else: file = Files.get_file_by_id_and_user_id(form_data.file_id, user.id, db=db) if file: try: - collection_name = form_data.collection_name if collection_name is None: - collection_name = f"file-{file.id}" + collection_name = f'file-{file.id}' if form_data.content: # Update the content in the file @@ -1685,22 +1556,20 @@ def process_file( try: # /files/{file_id}/data/content/update - VECTOR_DB_CLIENT.delete_collection( - collection_name=f"file-{file.id}" - ) - except: + VECTOR_DB_CLIENT.delete_collection(collection_name=f'file-{file.id}') + except Exception: # Audio file upload pipeline pass docs = [ Document( - page_content=form_data.content.replace("
", "\n"), + page_content=form_data.content.replace('
', '\n'), metadata={ **file.meta, - "name": file.filename, - "created_by": file.user_id, - "file_id": file.id, - "source": file.filename, + 'name': file.filename, + 'created_by': file.user_id, + 'file_id': file.id, + 'source': file.filename, }, ) ] @@ -1710,9 +1579,7 @@ def process_file( # Check if the file has already been processed and save the content # Usage: /knowledge/{id}/file/add, /knowledge/{id}/file/update - result = VECTOR_DB_CLIENT.query( - collection_name=f"file-{file.id}", filter={"file_id": file.id} - ) + result = VECTOR_DB_CLIENT.query(collection_name=f'file-{file.id}', filter={'file_id': file.id}) if result is not None and len(result.ids[0]) > 0: docs = [ @@ -1725,18 +1592,18 @@ def process_file( else: docs = [ Document( - page_content=file.data.get("content", ""), + page_content=file.data.get('content', ''), metadata={ **file.meta, - "name": file.filename, - "created_by": file.user_id, - "file_id": file.id, - "source": file.filename, + 'name': file.filename, + 'created_by': file.user_id, + 'file_id': file.id, + 'source': file.filename, }, ) ] - text_content = file.data.get("content", "") + text_content = file.data.get('content', '') else: # Process the file and save the content # Usage: /files/ @@ -1776,19 +1643,17 @@ def process_file( MINERU_API_TIMEOUT=request.app.state.config.MINERU_API_TIMEOUT, MINERU_PARAMS=request.app.state.config.MINERU_PARAMS, ) - docs = loader.load( - file.filename, file.meta.get("content_type"), file_path - ) + docs = loader.load(file.filename, file.meta.get('content_type'), file_path) docs = [ Document( page_content=doc.page_content, metadata={ **filter_metadata(doc.metadata), - "name": file.filename, - "created_by": file.user_id, - "file_id": file.id, - "source": file.filename, + 'name': file.filename, + 'created_by': file.user_id, + 'file_id': file.id, + 'source': file.filename, }, ) for doc in docs @@ -1796,34 +1661,34 @@ def process_file( else: docs = [ Document( - page_content=file.data.get("content", ""), + page_content=file.data.get('content', ''), metadata={ **file.meta, - "name": file.filename, - "created_by": file.user_id, - "file_id": file.id, - "source": file.filename, + 'name': file.filename, + 'created_by': file.user_id, + 'file_id': file.id, + 'source': file.filename, }, ) ] - text_content = " ".join([doc.page_content for doc in docs]) + text_content = ' '.join([doc.page_content for doc in docs]) - log.debug(f"text_content: {text_content}") + log.debug(f'text_content: {text_content}') Files.update_file_data_by_id( file.id, - {"content": text_content}, + {'content': text_content}, db=db, ) hash = calculate_sha256_string(text_content) if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL: - Files.update_file_data_by_id(file.id, {"status": "completed"}, db=db) + Files.update_file_data_by_id(file.id, {'status': 'completed'}, db=db) Files.update_file_hash_by_id(file.id, hash, db=db) return { - "status": True, - "collection_name": None, - "filename": file.filename, - "content": text_content, + 'status': True, + 'collection_name': None, + 'filename': file.filename, + 'content': text_content, } else: try: @@ -1838,14 +1703,14 @@ def process_file( docs=docs, collection_name=collection_name, metadata={ - "file_id": file.id, - "name": file.filename, - "hash": hash, + 'file_id': file.id, + 'name': file.filename, + 'hash': hash, }, add=(True if form_data.collection_name else False), user=user, ) - log.info(f"added {len(docs)} items to collection {collection_name}") + log.info(f'added {len(docs)} items to collection {collection_name}') if result: # Fresh session for the final update. @@ -1853,26 +1718,26 @@ def process_file( Files.update_file_metadata_by_id( file.id, { - "collection_name": collection_name, + 'collection_name': collection_name, }, db=session, ) Files.update_file_data_by_id( file.id, - {"status": "completed"}, + {'status': 'completed'}, db=session, ) Files.update_file_hash_by_id(file.id, hash, db=session) return { - "status": True, - "collection_name": collection_name, - "filename": file.filename, - "content": text_content, + 'status': True, + 'collection_name': collection_name, + 'filename': file.filename, + 'content': text_content, } else: - raise Exception("Error saving document to vector database") + raise Exception('Error saving document to vector database') except Exception as e: raise e @@ -1882,13 +1747,13 @@ def process_file( with get_db() as session: Files.update_file_data_by_id( file.id, - {"status": "failed"}, + {'status': 'failed'}, db=session, ) # Clear the hash so the file can be re-uploaded after fixing the issue Files.update_file_hash_by_id(file.id, None, db=session) - if "No pandoc was found" in str(e): + if 'No pandoc was found' in str(e): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.PANDOC_NOT_INSTALLED, @@ -1900,9 +1765,7 @@ def process_file( ) else: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) class ProcessTextForm(BaseModel): @@ -1911,7 +1774,7 @@ class ProcessTextForm(BaseModel): collection_name: Optional[str] = None -@router.post("/process/text") +@router.post('/process/text') async def process_text( request: Request, form_data: ProcessTextForm, @@ -1924,20 +1787,18 @@ async def process_text( docs = [ Document( page_content=form_data.content, - metadata={"name": form_data.name, "created_by": user.id}, + metadata={'name': form_data.name, 'created_by': user.id}, ) ] text_content = form_data.content - log.debug(f"text_content: {text_content}") + log.debug(f'text_content: {text_content}') - result = await run_in_threadpool( - save_docs_to_vector_db, request, docs, collection_name, user=user - ) + result = await run_in_threadpool(save_docs_to_vector_db, request, docs, collection_name, user=user) if result: return { - "status": True, - "collection_name": collection_name, - "content": text_content, + 'status': True, + 'collection_name': collection_name, + 'content': text_content, } else: raise HTTPException( @@ -1946,22 +1807,18 @@ async def process_text( ) -@router.post("/process/youtube") -@router.post("/process/web") +@router.post('/process/youtube') +@router.post('/process/web') async def process_web( request: Request, form_data: ProcessUrlForm, - process: bool = Query(True, description="Whether to process and save the content"), - overwrite: bool = Query( - True, description="Whether to overwrite existing collection" - ), + process: bool = Query(True, description='Whether to process and save the content'), + overwrite: bool = Query(True, description='Whether to overwrite existing collection'), user=Depends(get_verified_user), ): try: - content, docs = await run_in_threadpool( - get_content_from_url, request, form_data.url - ) - log.debug(f"text_content: {content}") + content, docs = await run_in_threadpool(get_content_from_url, request, form_data.url) + log.debug(f'text_content: {content}') if process: collection_name = form_data.collection_name @@ -1982,23 +1839,23 @@ async def process_web( collection_name = None return { - "status": True, - "collection_name": collection_name, - "filename": form_data.url, - "file": { - "data": { - "content": content, + 'status': True, + 'collection_name': collection_name, + 'filename': form_data.url, + 'file': { + 'data': { + 'content': content, }, - "meta": { - "name": form_data.url, - "source": form_data.url, + 'meta': { + 'name': form_data.url, + 'source': form_data.url, }, }, } else: return { - "status": True, - "content": content, + 'status': True, + 'content': content, } except Exception as e: log.exception(e) @@ -2008,9 +1865,7 @@ async def process_web( ) -def search_web( - request: Request, engine: str, query: str, user=None -) -> list[SearchResult]: +def search_web(request: Request, engine: str, query: str, user=None) -> list[SearchResult]: """Search the web using a search engine and return the results as a list of SearchResult objects. Will look for a search engine API key in environment variables in the following order: - SEARXNG_QUERY_URL @@ -2034,15 +1889,15 @@ def search_web( """ # TODO: add playwright to search the web - if engine == "ollama_cloud": + if engine == 'ollama_cloud': return search_ollama_cloud( - "https://ollama.com", + 'https://ollama.com', request.app.state.config.OLLAMA_CLOUD_WEB_SEARCH_API_KEY, query, request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) - elif engine == "perplexity_search": + elif engine == 'perplexity_search': if request.app.state.config.PERPLEXITY_API_KEY: return search_perplexity_search( request.app.state.config.PERPLEXITY_API_KEY, @@ -2053,10 +1908,10 @@ def search_web( user, ) else: - raise Exception("No PERPLEXITY_API_KEY found in environment variables") - elif engine == "searxng": + raise Exception('No PERPLEXITY_API_KEY found in environment variables') + elif engine == 'searxng': if request.app.state.config.SEARXNG_QUERY_URL: - searxng_kwargs = {"language": request.app.state.config.SEARXNG_LANGUAGE} + searxng_kwargs = {'language': request.app.state.config.SEARXNG_LANGUAGE} return search_searxng( request.app.state.config.SEARXNG_QUERY_URL, query, @@ -2065,8 +1920,8 @@ def search_web( **searxng_kwargs, ) else: - raise Exception("No SEARXNG_QUERY_URL found in environment variables") - elif engine == "yacy": + raise Exception('No SEARXNG_QUERY_URL found in environment variables') + elif engine == 'yacy': if request.app.state.config.YACY_QUERY_URL: return search_yacy( request.app.state.config.YACY_QUERY_URL, @@ -2077,12 +1932,9 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No YACY_QUERY_URL found in environment variables") - elif engine == "google_pse": - if ( - request.app.state.config.GOOGLE_PSE_API_KEY - and request.app.state.config.GOOGLE_PSE_ENGINE_ID - ): + raise Exception('No YACY_QUERY_URL found in environment variables') + elif engine == 'google_pse': + if request.app.state.config.GOOGLE_PSE_API_KEY and request.app.state.config.GOOGLE_PSE_ENGINE_ID: return search_google_pse( request.app.state.config.GOOGLE_PSE_API_KEY, request.app.state.config.GOOGLE_PSE_ENGINE_ID, @@ -2092,10 +1944,8 @@ def search_web( referer=request.app.state.config.WEBUI_URL, ) else: - raise Exception( - "No GOOGLE_PSE_API_KEY or GOOGLE_PSE_ENGINE_ID found in environment variables" - ) - elif engine == "brave": + raise Exception('No GOOGLE_PSE_API_KEY or GOOGLE_PSE_ENGINE_ID found in environment variables') + elif engine == 'brave': if request.app.state.config.BRAVE_SEARCH_API_KEY: return search_brave( request.app.state.config.BRAVE_SEARCH_API_KEY, @@ -2104,8 +1954,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No BRAVE_SEARCH_API_KEY found in environment variables") - elif engine == "kagi": + raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables') + elif engine == 'kagi': if request.app.state.config.KAGI_SEARCH_API_KEY: return search_kagi( request.app.state.config.KAGI_SEARCH_API_KEY, @@ -2114,8 +1964,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No KAGI_SEARCH_API_KEY found in environment variables") - elif engine == "mojeek": + raise Exception('No KAGI_SEARCH_API_KEY found in environment variables') + elif engine == 'mojeek': if request.app.state.config.MOJEEK_SEARCH_API_KEY: return search_mojeek( request.app.state.config.MOJEEK_SEARCH_API_KEY, @@ -2124,8 +1974,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No MOJEEK_SEARCH_API_KEY found in environment variables") - elif engine == "bocha": + raise Exception('No MOJEEK_SEARCH_API_KEY found in environment variables') + elif engine == 'bocha': if request.app.state.config.BOCHA_SEARCH_API_KEY: return search_bocha( request.app.state.config.BOCHA_SEARCH_API_KEY, @@ -2134,8 +1984,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No BOCHA_SEARCH_API_KEY found in environment variables") - elif engine == "serpstack": + raise Exception('No BOCHA_SEARCH_API_KEY found in environment variables') + elif engine == 'serpstack': if request.app.state.config.SERPSTACK_API_KEY: return search_serpstack( request.app.state.config.SERPSTACK_API_KEY, @@ -2145,8 +1995,8 @@ def search_web( https_enabled=request.app.state.config.SERPSTACK_HTTPS, ) else: - raise Exception("No SERPSTACK_API_KEY found in environment variables") - elif engine == "serper": + raise Exception('No SERPSTACK_API_KEY found in environment variables') + elif engine == 'serper': if request.app.state.config.SERPER_API_KEY: return search_serper( request.app.state.config.SERPER_API_KEY, @@ -2155,8 +2005,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No SERPER_API_KEY found in environment variables") - elif engine == "serply": + raise Exception('No SERPER_API_KEY found in environment variables') + elif engine == 'serply': if request.app.state.config.SERPLY_API_KEY: return search_serply( request.app.state.config.SERPLY_API_KEY, @@ -2165,8 +2015,8 @@ def search_web( filter_list=request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No SERPLY_API_KEY found in environment variables") - elif engine == "duckduckgo": + raise Exception('No SERPLY_API_KEY found in environment variables') + elif engine == 'duckduckgo': return search_duckduckgo( query, request.app.state.config.WEB_SEARCH_RESULT_COUNT, @@ -2174,7 +2024,7 @@ def search_web( concurrent_requests=request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS, backend=request.app.state.config.DDGS_BACKEND, ) - elif engine == "tavily": + elif engine == 'tavily': if request.app.state.config.TAVILY_API_KEY: return search_tavily( request.app.state.config.TAVILY_API_KEY, @@ -2183,8 +2033,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No TAVILY_API_KEY found in environment variables") - elif engine == "exa": + raise Exception('No TAVILY_API_KEY found in environment variables') + elif engine == 'exa': if request.app.state.config.EXA_API_KEY: return search_exa( request.app.state.config.EXA_API_KEY, @@ -2193,8 +2043,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No EXA_API_KEY found in environment variables") - elif engine == "searchapi": + raise Exception('No EXA_API_KEY found in environment variables') + elif engine == 'searchapi': if request.app.state.config.SEARCHAPI_API_KEY: return search_searchapi( request.app.state.config.SEARCHAPI_API_KEY, @@ -2204,8 +2054,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No SEARCHAPI_API_KEY found in environment variables") - elif engine == "serpapi": + raise Exception('No SEARCHAPI_API_KEY found in environment variables') + elif engine == 'serpapi': if request.app.state.config.SERPAPI_API_KEY: return search_serpapi( request.app.state.config.SERPAPI_API_KEY, @@ -2215,15 +2065,15 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No SERPAPI_API_KEY found in environment variables") - elif engine == "jina": + raise Exception('No SERPAPI_API_KEY found in environment variables') + elif engine == 'jina': return search_jina( request.app.state.config.JINA_API_KEY, query, request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.JINA_API_BASE_URL, ) - elif engine == "bing": + elif engine == 'bing': return search_bing( request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY, request.app.state.config.BING_SEARCH_V7_ENDPOINT, @@ -2232,7 +2082,7 @@ def search_web( request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) - elif engine == "azure": + elif engine == 'azure': if ( request.app.state.config.AZURE_AI_SEARCH_API_KEY and request.app.state.config.AZURE_AI_SEARCH_ENDPOINT @@ -2248,16 +2098,16 @@ def search_web( ) else: raise Exception( - "AZURE_AI_SEARCH_API_KEY, AZURE_AI_SEARCH_ENDPOINT, and AZURE_AI_SEARCH_INDEX_NAME are required for Azure AI Search" + 'AZURE_AI_SEARCH_API_KEY, AZURE_AI_SEARCH_ENDPOINT, and AZURE_AI_SEARCH_INDEX_NAME are required for Azure AI Search' ) - elif engine == "exa": + elif engine == 'exa': return search_exa( request.app.state.config.EXA_API_KEY, query, request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) - elif engine == "perplexity": + elif engine == 'perplexity': return search_perplexity( request.app.state.config.PERPLEXITY_API_KEY, query, @@ -2266,11 +2116,8 @@ def search_web( model=request.app.state.config.PERPLEXITY_MODEL, search_context_usage=request.app.state.config.PERPLEXITY_SEARCH_CONTEXT_USAGE, ) - elif engine == "sougou": - if ( - request.app.state.config.SOUGOU_API_SID - and request.app.state.config.SOUGOU_API_SK - ): + elif engine == 'sougou': + if request.app.state.config.SOUGOU_API_SID and request.app.state.config.SOUGOU_API_SK: return search_sougou( request.app.state.config.SOUGOU_API_SID, request.app.state.config.SOUGOU_API_SK, @@ -2279,10 +2126,8 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception( - "No SOUGOU_API_SID or SOUGOU_API_SK found in environment variables" - ) - elif engine == "firecrawl": + raise Exception('No SOUGOU_API_SID or SOUGOU_API_SK found in environment variables') + elif engine == 'firecrawl': return search_firecrawl( request.app.state.config.FIRECRAWL_API_BASE_URL, request.app.state.config.FIRECRAWL_API_KEY, @@ -2290,7 +2135,7 @@ def search_web( request.app.state.config.WEB_SEARCH_RESULT_COUNT, request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) - elif engine == "external": + elif engine == 'external': return search_external( request, request.app.state.config.EXTERNAL_WEB_SEARCH_URL, @@ -2300,7 +2145,7 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, user=user, ) - elif engine == "yandex": + elif engine == 'yandex': return search_yandex( request, request.app.state.config.YANDEX_WEB_SEARCH_URL, @@ -2311,7 +2156,7 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, user=user, ) - elif engine == "youcom": + elif engine == 'youcom': return search_youcom( request.app.state.config.YOUCOM_API_KEY, query, @@ -2319,21 +2164,19 @@ def search_web( request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST, ) else: - raise Exception("No search engine API key found in environment variables") + raise Exception('No search engine API key found in environment variables') -@router.post("/process/web/search") -async def process_web_search( - request: Request, form_data: SearchForm, user=Depends(get_verified_user) -): +@router.post('/process/web/search') +async def process_web_search(request: Request, form_data: SearchForm, user=Depends(get_verified_user)): if not request.app.state.config.ENABLE_WEB_SEARCH: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - if user.role != "admin" and not has_permission( - user.id, "features.web_search", request.app.state.config.USER_PERMISSIONS + if user.role != 'admin' and not has_permission( + user.id, 'features.web_search', request.app.state.config.USER_PERMISSIONS ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -2344,9 +2187,7 @@ async def process_web_search( result_items = [] try: - logging.debug( - f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}" - ) + logging.debug(f'trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}') # Use semaphore to limit concurrent requests based on WEB_SEARCH_CONCURRENT_REQUESTS # 0 or None = unlimited (previous behavior), positive number = limited concurrency @@ -2367,9 +2208,7 @@ async def search_query_with_semaphore(query): user, ) - search_tasks = [ - search_query_with_semaphore(query) for query in form_data.queries - ] + search_tasks = [search_query_with_semaphore(query) for query in form_data.queries] else: # Unlimited parallel execution (previous behavior) search_tasks = [ @@ -2393,7 +2232,7 @@ async def search_query_with_semaphore(query): urls.append(item.link) urls = list(dict.fromkeys(urls)) - log.debug(f"urls: {urls}") + log.debug(f'urls: {urls}') except Exception as e: log.exception(e) @@ -2406,27 +2245,25 @@ async def search_query_with_semaphore(query): if len(urls) == 0: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=ERROR_MESSAGES.DEFAULT("No results found from web search"), + detail=ERROR_MESSAGES.DEFAULT('No results found from web search'), ) try: if request.app.state.config.BYPASS_WEB_SEARCH_WEB_LOADER: - search_results = [ - item for result in search_results for item in result if result - ] + search_results = [item for result in search_results for item in result if result] docs = [ Document( page_content=result.snippet, metadata={ - "source": result.link, - "title": result.title, - "snippet": result.snippet, - "link": result.link, + 'source': result.link, + 'title': result.title, + 'snippet': result.snippet, + 'link': result.link, }, ) for result in search_results - if hasattr(result, "snippet") and result.snippet is not None + if hasattr(result, 'snippet') and result.snippet is not None ] else: loader = get_web_loader( @@ -2438,7 +2275,7 @@ async def search_query_with_semaphore(query): docs = await loader.aload() urls = [ - doc.metadata.get("source") for doc in docs if doc.metadata.get("source") + doc.metadata.get('source') for doc in docs if doc.metadata.get('source') ] # only keep the urls returned by the loader result_items = [ dict(item) for item in result_items if item.link in urls @@ -2446,26 +2283,22 @@ async def search_query_with_semaphore(query): if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: return { - "status": True, - "collection_name": None, - "filenames": urls, - "items": result_items, - "docs": [ + 'status': True, + 'collection_name': None, + 'filenames': urls, + 'items': result_items, + 'docs': [ { - "content": doc.page_content, - "metadata": doc.metadata, + 'content': doc.page_content, + 'metadata': doc.metadata, } for doc in docs ], - "loaded_count": len(docs), + 'loaded_count': len(docs), } else: # Create a single collection for all documents - collection_name = ( - f"web-search-{calculate_sha256_string('-'.join(form_data.queries))}"[ - :63 - ] - ) + collection_name = f'web-search-{calculate_sha256_string("-".join(form_data.queries))}'[:63] try: await run_in_threadpool( @@ -2477,14 +2310,14 @@ async def search_query_with_semaphore(query): user=user, ) except Exception as e: - log.debug(f"error saving docs: {e}") + log.debug(f'error saving docs: {e}') return { - "status": True, - "collection_names": [collection_name], - "items": result_items, - "filenames": urls, - "loaded_count": len(docs), + 'status': True, + 'collection_names': [collection_name], + 'items': result_items, + 'filenames': urls, + 'loaded_count': len(docs), } except Exception as e: log.exception(e) @@ -2500,20 +2333,20 @@ def _validate_collection_access(collection_names: list[str], user) -> None: Enforces ownership on user-memory-* and file-* collections. Admins bypass this check. """ - if user.role == "admin": + if user.role == 'admin': return for name in collection_names: - if name.startswith("user-memory-") and name != f"user-memory-{user.id}": + if name.startswith('user-memory-') and name != f'user-memory-{user.id}': raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, ) - elif name.startswith("file-"): - file_id = name[len("file-") :] + elif name.startswith('file-'): + file_id = name[len('file-') :] if not has_access_to_file( file_id=file_id, - access_type="read", + access_type='read', user=user, ): raise HTTPException( @@ -2531,7 +2364,7 @@ class QueryDocForm(BaseModel): hybrid: Optional[bool] = None -@router.post("/query/doc") +@router.post('/query/doc') async def query_doc_handler( request: Request, form_data: QueryDocForm, @@ -2540,9 +2373,7 @@ async def query_doc_handler( _validate_collection_access([form_data.collection_name], user) try: - if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and ( - form_data.hybrid is None or form_data.hybrid - ): + if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): collection_results = {} collection_results[form_data.collection_name] = VECTOR_DB_CLIENT.get( collection_name=form_data.collection_name @@ -2556,21 +2387,12 @@ async def query_doc_handler( ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, reranking_function=( - ( - lambda query, documents: request.app.state.RERANKING_FUNCTION( - query, documents, user=user - ) - ) + (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user)) if request.app.state.RERANKING_FUNCTION else None ), - k_reranker=form_data.k_reranker - or request.app.state.config.TOP_K_RERANKER, - r=( - form_data.r - if form_data.r - else request.app.state.config.RELEVANCE_THRESHOLD - ), + k_reranker=form_data.k_reranker or request.app.state.config.TOP_K_RERANKER, + r=(form_data.r if form_data.r else request.app.state.config.RELEVANCE_THRESHOLD), hybrid_bm25_weight=( form_data.hybrid_bm25_weight if form_data.hybrid_bm25_weight @@ -2607,7 +2429,7 @@ class QueryCollectionsForm(BaseModel): enable_enriched_texts: Optional[bool] = None -@router.post("/query/collection") +@router.post('/query/collection') async def query_collection_handler( request: Request, form_data: QueryCollectionsForm, @@ -2616,9 +2438,7 @@ async def query_collection_handler( _validate_collection_access(form_data.collection_names, user) try: - if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and ( - form_data.hybrid is None or form_data.hybrid - ): + if request.app.state.config.ENABLE_RAG_HYBRID_SEARCH and (form_data.hybrid is None or form_data.hybrid): return await query_collection_with_hybrid_search( collection_names=form_data.collection_names, queries=[form_data.query], @@ -2627,21 +2447,12 @@ async def query_collection_handler( ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, reranking_function=( - ( - lambda query, documents: request.app.state.RERANKING_FUNCTION( - query, documents, user=user - ) - ) + (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user)) if request.app.state.RERANKING_FUNCTION else None ), - k_reranker=form_data.k_reranker - or request.app.state.config.TOP_K_RERANKER, - r=( - form_data.r - if form_data.r - else request.app.state.config.RELEVANCE_THRESHOLD - ), + k_reranker=form_data.k_reranker or request.app.state.config.TOP_K_RERANKER, + r=(form_data.r if form_data.r else request.app.state.config.RELEVANCE_THRESHOLD), hybrid_bm25_weight=( form_data.hybrid_bm25_weight if form_data.hybrid_bm25_weight @@ -2655,6 +2466,7 @@ async def query_collection_handler( ) else: return await query_collection( + request, collection_names=form_data.collection_names, queries=[form_data.query], embedding_function=lambda query, prefix: request.app.state.EMBEDDING_FUNCTION( @@ -2683,7 +2495,7 @@ class DeleteForm(BaseModel): file_id: str -@router.post("/delete") +@router.post('/delete') def delete_entries_from_collection( form_data: DeleteForm, user=Depends(get_admin_user), @@ -2701,25 +2513,25 @@ def delete_entries_from_collection( VECTOR_DB_CLIENT.delete( collection_name=form_data.collection_name, - metadata={"hash": hash}, + metadata={'hash': hash}, ) - return {"status": True} + return {'status': True} else: - return {"status": False} + return {'status': False} except Exception as e: log.exception(e) - return {"status": False} + return {'status': False} -@router.post("/reset/db") +@router.post('/reset/db') def reset_vector_db(user=Depends(get_admin_user), db: Session = Depends(get_session)): VECTOR_DB_CLIENT.reset() Knowledges.delete_all_knowledge(db=db) -@router.post("/reset/uploads") +@router.post('/reset/uploads') def reset_upload_dir(user=Depends(get_admin_user)) -> bool: - folder = f"{UPLOAD_DIR}" + folder = f'{UPLOAD_DIR}' try: # Check if the directory exists if os.path.exists(folder): @@ -2732,23 +2544,19 @@ def reset_upload_dir(user=Depends(get_admin_user)) -> bool: elif os.path.isdir(file_path): shutil.rmtree(file_path) # Remove the directory except Exception as e: - log.exception(f"Failed to delete {file_path}. Reason: {e}") + log.exception(f'Failed to delete {file_path}. Reason: {e}') else: - log.warning(f"The directory {folder} does not exist") + log.warning(f'The directory {folder} does not exist') except Exception as e: - log.exception(f"Failed to process the directory {folder}. Reason: {e}") + log.exception(f'Failed to process the directory {folder}. Reason: {e}') return True -if ENV == "dev": +if ENV == 'dev': - @router.get("/ef/{text}") - async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"): - return { - "result": await request.app.state.EMBEDDING_FUNCTION( - text, prefix=RAG_EMBEDDING_QUERY_PREFIX - ) - } + @router.get('/ef/{text}') + async def get_embeddings(request: Request, text: Optional[str] = 'Hello World!'): + return {'result': await request.app.state.EMBEDDING_FUNCTION(text, prefix=RAG_EMBEDDING_QUERY_PREFIX)} class BatchProcessFilesForm(BaseModel): @@ -2767,7 +2575,7 @@ class BatchProcessFilesResponse(BaseModel): errors: List[BatchProcessFilesResult] -@router.post("/process/files/batch") +@router.post('/process/files/batch') async def process_files_batch( request: Request, form_data: BatchProcessFilesForm, @@ -2799,31 +2607,31 @@ async def process_files_batch( file_errors.append( BatchProcessFilesResult( file_id=file.id, - status="failed", - error="File not found", + status='failed', + error='File not found', ) ) continue - if db_file.user_id != user.id and user.role != "admin": + if db_file.user_id != user.id and user.role != 'admin': file_errors.append( BatchProcessFilesResult( file_id=file.id, - status="failed", - error="Permission denied: not file owner", + status='failed', + error='Permission denied: not file owner', ) ) continue - text_content = file.data.get("content", "") + text_content = file.data.get('content', '') docs: List[Document] = [ Document( - page_content=text_content.replace("
", "\n"), + page_content=text_content.replace('
', '\n'), metadata={ **file.meta, - "name": file.filename, - "created_by": file.user_id, - "file_id": file.id, - "source": file.filename, + 'name': file.filename, + 'created_by': file.user_id, + 'file_id': file.id, + 'source': file.filename, }, ) ] @@ -2833,18 +2641,14 @@ async def process_files_batch( file_updates.append( FileUpdateForm( hash=calculate_sha256_string(text_content), - data={"content": text_content}, + data={'content': text_content}, ) ) - file_results.append( - BatchProcessFilesResult(file_id=file.id, status="prepared") - ) + file_results.append(BatchProcessFilesResult(file_id=file.id, status='prepared')) except Exception as e: - log.error(f"process_files_batch: Error processing file {file.id}: {str(e)}") - file_errors.append( - BatchProcessFilesResult(file_id=file.id, status="failed", error=str(e)) - ) + log.error(f'process_files_batch: Error processing file {file.id}: {str(e)}') + file_errors.append(BatchProcessFilesResult(file_id=file.id, status='failed', error=str(e))) # Save all documents in one batch if all_docs: @@ -2861,18 +2665,12 @@ async def process_files_batch( # Update all files with collection name for file_update, file_result in zip(file_updates, file_results): Files.update_file_by_id(id=file_result.file_id, form_data=file_update) - file_result.status = "completed" + file_result.status = 'completed' except Exception as e: - log.error( - f"process_files_batch: Error saving documents to vector DB: {str(e)}" - ) + log.error(f'process_files_batch: Error saving documents to vector DB: {str(e)}') for file_result in file_results: - file_result.status = "failed" - file_errors.append( - BatchProcessFilesResult( - file_id=file_result.file_id, status="failed", error=str(e) - ) - ) + file_result.status = 'failed' + file_errors.append(BatchProcessFilesResult(file_id=file_result.file_id, status='failed', error=str(e))) return BatchProcessFilesResponse(results=file_results, errors=file_errors) diff --git a/backend/open_webui/routers/scim.py b/backend/open_webui/routers/scim.py index 4d56a7e97f..56923bc447 100644 --- a/backend/open_webui/routers/scim.py +++ b/backend/open_webui/routers/scim.py @@ -37,32 +37,32 @@ router = APIRouter() # SCIM 2.0 Schema URIs -SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" -SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" -SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse" -SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" +SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User' +SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group' +SCIM_LIST_RESPONSE_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse' +SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error' # SCIM Resource Types -SCIM_RESOURCE_TYPE_USER = "User" -SCIM_RESOURCE_TYPE_GROUP = "Group" +SCIM_RESOURCE_TYPE_USER = 'User' +SCIM_RESOURCE_TYPE_GROUP = 'Group' def scim_error(status_code: int, detail: str, scim_type: Optional[str] = None): """Create a SCIM-compliant error response""" error_body = { - "schemas": [SCIM_ERROR_SCHEMA], - "status": str(status_code), - "detail": detail, + 'schemas': [SCIM_ERROR_SCHEMA], + 'status': str(status_code), + 'detail': detail, } if scim_type: - error_body["scimType"] = scim_type + error_body['scimType'] = scim_type elif status_code == 404: - error_body["scimType"] = "invalidValue" + error_body['scimType'] = 'invalidValue' elif status_code == 409: - error_body["scimType"] = "uniqueness" + error_body['scimType'] = 'uniqueness' elif status_code == 400: - error_body["scimType"] = "invalidSyntax" + error_body['scimType'] = 'invalidSyntax' return JSONResponse(status_code=status_code, content=error_body) @@ -101,7 +101,7 @@ class SCIMEmail(BaseModel): """SCIM Email""" value: str - type: Optional[str] = "work" + type: Optional[str] = 'work' primary: bool = True display: Optional[str] = None @@ -110,7 +110,7 @@ class SCIMPhoto(BaseModel): """SCIM Photo""" value: str - type: Optional[str] = "photo" + type: Optional[str] = 'photo' primary: bool = True display: Optional[str] = None @@ -119,8 +119,8 @@ class SCIMGroupMember(BaseModel): """SCIM Group Member""" value: str # User ID - ref: Optional[str] = Field(None, alias="$ref") - type: Optional[str] = "User" + ref: Optional[str] = Field(None, alias='$ref') + type: Optional[str] = 'User' display: Optional[str] = None @@ -227,13 +227,11 @@ class SCIMPatchOperation(BaseModel): class SCIMPatchRequest(BaseModel): """SCIM Patch Request""" - schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] + schemas: List[str] = ['urn:ietf:params:scim:api:messages:2.0:PatchOp'] Operations: List[SCIMPatchOperation] -def get_scim_auth( - request: Request, authorization: Optional[str] = Header(None) -) -> bool: +def get_scim_auth(request: Request, authorization: Optional[str] = Header(None)) -> bool: """ Verify SCIM authentication Checks for SCIM-specific bearer token configured in the system @@ -241,8 +239,8 @@ def get_scim_auth( if not authorization: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authorization header required", - headers={"WWW-Authenticate": "Bearer"}, + detail='Authorization header required', + headers={'WWW-Authenticate': 'Bearer'}, ) try: @@ -250,42 +248,40 @@ def get_scim_auth( if len(parts) != 2: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authorization format. Expected: Bearer ", + detail='Invalid authorization format. Expected: Bearer ', ) scheme, token = parts - if scheme.lower() != "bearer": + if scheme.lower() != 'bearer': raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication scheme", + detail='Invalid authentication scheme', ) # Check if SCIM is enabled - enable_scim = getattr(request.app.state, "ENABLE_SCIM", False) - log.info( - f"SCIM auth check - raw ENABLE_SCIM: {enable_scim}, type: {type(enable_scim)}" - ) + enable_scim = getattr(request.app.state, 'ENABLE_SCIM', False) + log.info(f'SCIM auth check - raw ENABLE_SCIM: {enable_scim}, type: {type(enable_scim)}') # Handle both PersistentConfig and direct value - if hasattr(enable_scim, "value"): + if hasattr(enable_scim, 'value'): enable_scim = enable_scim.value if not enable_scim: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="SCIM is not enabled", + detail='SCIM is not enabled', ) # Verify the SCIM token - scim_token = getattr(request.app.state, "SCIM_TOKEN", None) + scim_token = getattr(request.app.state, 'SCIM_TOKEN', None) # Handle both PersistentConfig and direct value - if hasattr(scim_token, "value"): + if hasattr(scim_token, 'value'): scim_token = scim_token.value - log.debug(f"SCIM token configured: {bool(scim_token)}") + log.debug(f'SCIM token configured: {bool(scim_token)}') if not scim_token or token != scim_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid SCIM token", + detail='Invalid SCIM token', ) return True @@ -293,13 +289,13 @@ def get_scim_auth( # Re-raise HTTP exceptions as-is raise except Exception as e: - log.error(f"SCIM authentication error: {e}") + log.error(f'SCIM authentication error: {e}') import traceback - log.error(f"Traceback: {traceback.format_exc()}") + log.error(f'Traceback: {traceback.format_exc()}') raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication failed", + detail='Authentication failed', ) @@ -311,8 +307,8 @@ def get_external_id(user: UserModel) -> Optional[str]: if not user.scim: return None for provider_data in user.scim.values(): - if isinstance(provider_data, dict) and "external_id" in provider_data: - return provider_data["external_id"] + if isinstance(provider_data, dict) and 'external_id' in provider_data: + return provider_data['external_id'] return None @@ -324,7 +320,7 @@ def get_scim_provider() -> str: if not SCIM_AUTH_PROVIDER: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="SCIM_AUTH_PROVIDER environment variable is required when SCIM is enabled", + detail='SCIM_AUTH_PROVIDER environment variable is required when SCIM is enabled', ) return SCIM_AUTH_PROVIDER @@ -343,18 +339,18 @@ def find_user_by_external_id(external_id: str, db=None) -> Optional[UserModel]: def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: """Convert internal User model to SCIM User""" # Parse display name into name components - name_parts = user.name.split(" ", 1) if user.name else ["", ""] - given_name = name_parts[0] if name_parts else "" - family_name = name_parts[1] if len(name_parts) > 1 else "" + name_parts = user.name.split(' ', 1) if user.name else ['', ''] + given_name = name_parts[0] if name_parts else '' + family_name = name_parts[1] if len(name_parts) > 1 else '' # Get user's groups user_groups = Groups.get_groups_by_member_id(user.id, db=db) groups = [ { - "value": group.id, - "display": group.name, - "$ref": f"{request.base_url}api/v1/scim/v2/Groups/{group.id}", - "type": "direct", + 'value': group.id, + 'display': group.name, + '$ref': f'{request.base_url}api/v1/scim/v2/Groups/{group.id}', + 'type': 'direct', } for group in user_groups ] @@ -370,22 +366,14 @@ def user_to_scim(user: UserModel, request: Request, db=None) -> SCIMUser: ), displayName=user.name, emails=[SCIMEmail(value=user.email)], - active=user.role != "pending", - photos=( - [SCIMPhoto(value=user.profile_image_url)] - if user.profile_image_url - else None - ), + active=user.role != 'pending', + photos=([SCIMPhoto(value=user.profile_image_url)] if user.profile_image_url else None), groups=groups if groups else None, meta=SCIMMeta( resourceType=SCIM_RESOURCE_TYPE_USER, - created=datetime.fromtimestamp( - user.created_at, tz=timezone.utc - ).isoformat(), - lastModified=datetime.fromtimestamp( - user.updated_at, tz=timezone.utc - ).isoformat(), - location=f"{request.base_url}api/v1/scim/v2/Users/{user.id}", + created=datetime.fromtimestamp(user.created_at, tz=timezone.utc).isoformat(), + lastModified=datetime.fromtimestamp(user.updated_at, tz=timezone.utc).isoformat(), + location=f'{request.base_url}api/v1/scim/v2/Users/{user.id}', ), ) @@ -399,7 +387,7 @@ def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup: members = [ SCIMGroupMember( value=user.id, - ref=f"{request.base_url}api/v1/scim/v2/Users/{user.id}", + ref=f'{request.base_url}api/v1/scim/v2/Users/{user.id}', display=user.name, ) for user in users @@ -411,108 +399,104 @@ def group_to_scim(group: GroupModel, request: Request, db=None) -> SCIMGroup: members=members, meta=SCIMMeta( resourceType=SCIM_RESOURCE_TYPE_GROUP, - created=datetime.fromtimestamp( - group.created_at, tz=timezone.utc - ).isoformat(), - lastModified=datetime.fromtimestamp( - group.updated_at, tz=timezone.utc - ).isoformat(), - location=f"{request.base_url}api/v1/scim/v2/Groups/{group.id}", + created=datetime.fromtimestamp(group.created_at, tz=timezone.utc).isoformat(), + lastModified=datetime.fromtimestamp(group.updated_at, tz=timezone.utc).isoformat(), + location=f'{request.base_url}api/v1/scim/v2/Groups/{group.id}', ), ) # SCIM Service Provider Config -@router.get("/ServiceProviderConfig") +@router.get('/ServiceProviderConfig') async def get_service_provider_config(): """Get SCIM Service Provider Configuration""" return { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], - "patch": {"supported": True}, - "bulk": {"supported": False, "maxOperations": 1000, "maxPayloadSize": 1048576}, - "filter": {"supported": True, "maxResults": 200}, - "changePassword": {"supported": False}, - "sort": {"supported": False}, - "etag": {"supported": False}, - "authenticationSchemes": [ + 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'], + 'patch': {'supported': True}, + 'bulk': {'supported': False, 'maxOperations': 1000, 'maxPayloadSize': 1048576}, + 'filter': {'supported': True, 'maxResults': 200}, + 'changePassword': {'supported': False}, + 'sort': {'supported': False}, + 'etag': {'supported': False}, + 'authenticationSchemes': [ { - "type": "oauthbearertoken", - "name": "OAuth Bearer Token", - "description": "Authentication using OAuth 2.0 Bearer Token", + 'type': 'oauthbearertoken', + 'name': 'OAuth Bearer Token', + 'description': 'Authentication using OAuth 2.0 Bearer Token', } ], } # SCIM Resource Types -@router.get("/ResourceTypes") +@router.get('/ResourceTypes') async def get_resource_types(request: Request): """Get SCIM Resource Types""" return [ { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], - "id": "User", - "name": "User", - "endpoint": "/Users", - "schema": SCIM_USER_SCHEMA, - "meta": { - "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/User", - "resourceType": "ResourceType", + 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], + 'id': 'User', + 'name': 'User', + 'endpoint': '/Users', + 'schema': SCIM_USER_SCHEMA, + 'meta': { + 'location': f'{request.base_url}api/v1/scim/v2/ResourceTypes/User', + 'resourceType': 'ResourceType', }, }, { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"], - "id": "Group", - "name": "Group", - "endpoint": "/Groups", - "schema": SCIM_GROUP_SCHEMA, - "meta": { - "location": f"{request.base_url}api/v1/scim/v2/ResourceTypes/Group", - "resourceType": "ResourceType", + 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'], + 'id': 'Group', + 'name': 'Group', + 'endpoint': '/Groups', + 'schema': SCIM_GROUP_SCHEMA, + 'meta': { + 'location': f'{request.base_url}api/v1/scim/v2/ResourceTypes/Group', + 'resourceType': 'ResourceType', }, }, ] # SCIM Schemas -@router.get("/Schemas") +@router.get('/Schemas') async def get_schemas(): """Get SCIM Schemas""" return [ { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "id": SCIM_USER_SCHEMA, - "name": "User", - "description": "User Account", - "attributes": [ + 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Schema'], + 'id': SCIM_USER_SCHEMA, + 'name': 'User', + 'description': 'User Account', + 'attributes': [ { - "name": "userName", - "type": "string", - "required": True, - "uniqueness": "server", + 'name': 'userName', + 'type': 'string', + 'required': True, + 'uniqueness': 'server', }, - {"name": "displayName", "type": "string", "required": True}, + {'name': 'displayName', 'type': 'string', 'required': True}, { - "name": "emails", - "type": "complex", - "multiValued": True, - "required": True, + 'name': 'emails', + 'type': 'complex', + 'multiValued': True, + 'required': True, }, - {"name": "active", "type": "boolean", "required": False}, + {'name': 'active', 'type': 'boolean', 'required': False}, ], }, { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Schema"], - "id": SCIM_GROUP_SCHEMA, - "name": "Group", - "description": "Group", - "attributes": [ - {"name": "displayName", "type": "string", "required": True}, + 'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Schema'], + 'id': SCIM_GROUP_SCHEMA, + 'name': 'Group', + 'description': 'Group', + 'attributes': [ + {'name': 'displayName', 'type': 'string', 'required': True}, { - "name": "members", - "type": "complex", - "multiValued": True, - "required": False, + 'name': 'members', + 'type': 'complex', + 'multiValued': True, + 'required': False, }, ], }, @@ -520,7 +504,7 @@ async def get_schemas(): # Users endpoints -@router.get("/Users", response_model=SCIMListResponse) +@router.get('/Users', response_model=SCIMListResponse) async def get_users( request: Request, startIndex: int = Query(1), @@ -540,24 +524,24 @@ async def get_users( # Get users from database if filter: # Simple filter parsing - supports userName eq, externalId eq - if "userName eq" in filter: + if 'userName eq' in filter: email = filter.split('"')[1] user = Users.get_user_by_email(email, db=db) users_list = [user] if user else [] total = 1 if user else 0 - elif "externalId eq" in filter: + elif 'externalId eq' in filter: external_id = filter.split('"')[1] user = find_user_by_external_id(external_id, db=db) users_list = [user] if user else [] total = 1 if user else 0 else: response = Users.get_users(skip=skip, limit=limit, db=db) - users_list = response["users"] - total = response["total"] + users_list = response['users'] + total = response['total'] else: response = Users.get_users(skip=skip, limit=limit, db=db) - users_list = response["users"] - total = response["total"] + users_list = response['users'] + total = response['total'] # Convert to SCIM format scim_users = [user_to_scim(user, request, db=db) for user in users_list] @@ -570,7 +554,7 @@ async def get_users( ) -@router.get("/Users/{user_id}", response_model=SCIMUser) +@router.get('/Users/{user_id}', response_model=SCIMUser) async def get_user( user_id: str, request: Request, @@ -580,14 +564,12 @@ async def get_user( """Get SCIM User by ID""" user = Users.get_user_by_id(user_id, db=db) if not user: - return scim_error( - status_code=status.HTTP_404_NOT_FOUND, detail=f"User {user_id} not found" - ) + return scim_error(status_code=status.HTTP_404_NOT_FOUND, detail=f'User {user_id} not found') return user_to_scim(user, request, db=db) -@router.post("/Users", response_model=SCIMUser, status_code=status.HTTP_201_CREATED) +@router.post('/Users', response_model=SCIMUser, status_code=status.HTTP_201_CREATED) async def create_user( request: Request, user_data: SCIMUserCreateRequest, @@ -601,7 +583,7 @@ async def create_user( if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"User with externalId {user_data.externalId} already exists", + detail=f'User with externalId {user_data.externalId} already exists', ) # Determine primary email (lowercased per RFC 5321) @@ -617,7 +599,7 @@ async def create_user( if existing_user: raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail=f"User with email {email} already exists", + detail=f'User with email {email} already exists', ) # Create user @@ -629,10 +611,10 @@ async def create_user( if user_data.name.formatted: name = user_data.name.formatted elif user_data.name.givenName or user_data.name.familyName: - name = f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip() + name = f'{user_data.name.givenName or ""} {user_data.name.familyName or ""}'.strip() # Get profile image if provided - profile_image = "/user.png" + profile_image = '/user.png' if user_data.photos and len(user_data.photos) > 0: profile_image = user_data.photos[0].value @@ -641,14 +623,14 @@ async def create_user( name=name, email=email, profile_image_url=profile_image, - role="user" if user_data.active else "pending", + role='user' if user_data.active else 'pending', db=db, ) if not new_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create user", + detail='Failed to create user', ) # Store externalId in the scim field @@ -660,7 +642,7 @@ async def create_user( return user_to_scim(new_user, request, db=db) -@router.put("/Users/{user_id}", response_model=SCIMUser) +@router.put('/Users/{user_id}', response_model=SCIMUser) async def update_user( user_id: str, request: Request, @@ -673,39 +655,37 @@ async def update_user( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"User {user_id} not found", + detail=f'User {user_id} not found', ) # Build update dict update_data = {} if user_data.userName: - update_data["email"] = user_data.userName + update_data['email'] = user_data.userName if user_data.displayName: - update_data["name"] = user_data.displayName + update_data['name'] = user_data.displayName elif user_data.name: if user_data.name.formatted: - update_data["name"] = user_data.name.formatted + update_data['name'] = user_data.name.formatted elif user_data.name.givenName or user_data.name.familyName: - update_data["name"] = ( - f"{user_data.name.givenName or ''} {user_data.name.familyName or ''}".strip() - ) + update_data['name'] = f'{user_data.name.givenName or ""} {user_data.name.familyName or ""}'.strip() if user_data.emails and len(user_data.emails) > 0: - update_data["email"] = user_data.emails[0].value + update_data['email'] = user_data.emails[0].value if user_data.active is not None: - update_data["role"] = "user" if user_data.active else "pending" + update_data['role'] = 'user' if user_data.active else 'pending' if user_data.photos and len(user_data.photos) > 0: - update_data["profile_image_url"] = user_data.photos[0].value + update_data['profile_image_url'] = user_data.photos[0].value updated_user = Users.update_user_by_id(user_id, update_data, db=db) if not updated_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update user", + detail='Failed to update user', ) # Update externalId in the scim field @@ -717,7 +697,7 @@ async def update_user( return user_to_scim(updated_user, request, db=db) -@router.patch("/Users/{user_id}", response_model=SCIMUser) +@router.patch('/Users/{user_id}', response_model=SCIMUser) async def patch_user( user_id: str, request: Request, @@ -730,7 +710,7 @@ async def patch_user( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"User {user_id} not found", + detail=f'User {user_id} not found', ) update_data = {} @@ -740,18 +720,18 @@ async def patch_user( path = operation.path value = operation.value - if op == "replace": - if path == "active": - update_data["role"] = "user" if value else "pending" - elif path == "userName": - update_data["email"] = value - elif path == "displayName": - update_data["name"] = value - elif path == "emails[primary eq true].value": - update_data["email"] = value - elif path == "name.formatted": - update_data["name"] = value - elif path == "externalId": + if op == 'replace': + if path == 'active': + update_data['role'] = 'user' if value else 'pending' + elif path == 'userName': + update_data['email'] = value + elif path == 'displayName': + update_data['name'] = value + elif path == 'emails[primary eq true].value': + update_data['email'] = value + elif path == 'name.formatted': + update_data['name'] = value + elif path == 'externalId': provider = get_scim_provider() Users.update_user_scim_by_id(user_id, provider, value, db=db) @@ -761,7 +741,7 @@ async def patch_user( if not updated_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update user", + detail='Failed to update user', ) else: updated_user = user @@ -769,7 +749,7 @@ async def patch_user( return user_to_scim(updated_user, request, db=db) -@router.delete("/Users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/Users/{user_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: str, request: Request, @@ -781,21 +761,21 @@ async def delete_user( if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"User {user_id} not found", + detail=f'User {user_id} not found', ) success = Users.delete_user_by_id(user_id, db=db) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete user", + detail='Failed to delete user', ) return None # Groups endpoints -@router.get("/Groups", response_model=SCIMListResponse) +@router.get('/Groups', response_model=SCIMListResponse) async def get_groups( request: Request, startIndex: int = Query(1), @@ -810,8 +790,17 @@ async def get_groups( startIndex = max(1, startIndex) count = max(0, min(100, count)) - # Get all groups - groups_list = Groups.get_all_groups(db=db) + # Get groups, applying filter if provided + if filter: + if 'displayName eq' in filter: + display_name = filter.split('"')[1] + group = Groups.get_group_by_name(display_name, db=db) + groups_list = [group] if group else [] + else: + # Unrecognized filter โ€” fall back to all groups + groups_list = Groups.get_all_groups(db=db) + else: + groups_list = Groups.get_all_groups(db=db) # Apply pagination total = len(groups_list) @@ -830,7 +819,7 @@ async def get_groups( ) -@router.get("/Groups/{group_id}", response_model=SCIMGroup) +@router.get('/Groups/{group_id}', response_model=SCIMGroup) async def get_group( group_id: str, request: Request, @@ -842,13 +831,13 @@ async def get_group( if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Group {group_id} not found", + detail=f'Group {group_id} not found', ) return group_to_scim(group, request, db=db) -@router.post("/Groups", response_model=SCIMGroup, status_code=status.HTTP_201_CREATED) +@router.post('/Groups', response_model=SCIMGroup, status_code=status.HTTP_201_CREATED) async def create_group( request: Request, group_data: SCIMGroupCreateRequest, @@ -867,7 +856,7 @@ async def create_group( form = GroupForm( name=group_data.displayName, - description="", + description='', ) # Need to get the creating user's ID - we'll use the first admin @@ -875,14 +864,14 @@ async def create_group( if not admin_user: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="No admin user found", + detail='No admin user found', ) new_group = Groups.insert_new_group(admin_user.id, form, db=db) if not new_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create group", + detail='Failed to create group', ) # Add members if provided @@ -902,7 +891,7 @@ async def create_group( return group_to_scim(new_group, request, db=db) -@router.put("/Groups/{group_id}", response_model=SCIMGroup) +@router.put('/Groups/{group_id}', response_model=SCIMGroup) async def update_group( group_id: str, request: Request, @@ -915,7 +904,7 @@ async def update_group( if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Group {group_id} not found", + detail=f'Group {group_id} not found', ) # Build update form @@ -936,13 +925,13 @@ async def update_group( if not updated_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update group", + detail='Failed to update group', ) return group_to_scim(updated_group, request, db=db) -@router.patch("/Groups/{group_id}", response_model=SCIMGroup) +@router.patch('/Groups/{group_id}', response_model=SCIMGroup) async def patch_group( group_id: str, request: Request, @@ -955,7 +944,7 @@ async def patch_group( if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Group {group_id} not found", + detail=f'Group {group_id} not found', ) from open_webui.models.groups import GroupUpdateForm @@ -970,26 +959,22 @@ async def patch_group( path = operation.path value = operation.value - if op == "replace": - if path == "displayName": + if op == 'replace': + if path == 'displayName': update_form.name = value - elif path == "members": + elif path == 'members': # Replace all members - Groups.set_group_user_ids_by_id( - group_id, [member["value"] for member in value], db=db - ) + Groups.set_group_user_ids_by_id(group_id, [member['value'] for member in value], db=db) - elif op == "add": - if path == "members": + elif op == 'add': + if path == 'members': # Add members if isinstance(value, list): for member in value: - if isinstance(member, dict) and "value" in member: - Groups.add_users_to_group( - group_id, [member["value"]], db=db - ) - elif op == "remove": - if path and path.startswith("members[value eq"): + if isinstance(member, dict) and 'value' in member: + Groups.add_users_to_group(group_id, [member['value']], db=db) + elif op == 'remove': + if path and path.startswith('members[value eq'): # Remove specific member member_id = path.split('"')[1] Groups.remove_users_from_group(group_id, [member_id], db=db) @@ -999,13 +984,13 @@ async def patch_group( if not updated_group: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update group", + detail='Failed to update group', ) return group_to_scim(updated_group, request, db=db) -@router.delete("/Groups/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete('/Groups/{group_id}', status_code=status.HTTP_204_NO_CONTENT) async def delete_group( group_id: str, request: Request, @@ -1017,14 +1002,14 @@ async def delete_group( if not group: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Group {group_id} not found", + detail=f'Group {group_id} not found', ) success = Groups.delete_group_by_id(group_id, db=db) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete group", + detail='Failed to delete group', ) return None diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py index e18b561321..1838914e4a 100644 --- a/backend/open_webui/routers/skills.py +++ b/backend/open_webui/routers/skills.py @@ -36,18 +36,16 @@ ############################ -@router.get("/", response_model=list[SkillUserResponse]) +@router.get('/', response_model=list[SkillUserResponse]) async def get_skills( request: Request, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: skills = Skills.get_skills(db=db) else: - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} all_skills = Skills.get_skills(db=db) skills = [ skill @@ -55,9 +53,9 @@ async def get_skills( if skill.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="read", + permission='read', user_group_ids=user_group_ids, db=db, ) @@ -71,7 +69,7 @@ async def get_skills( ############################ -@router.get("/list", response_model=SkillAccessListResponse) +@router.get('/list', response_model=SkillAccessListResponse) async def get_skill_list( query: Optional[str] = None, view_option: Optional[str] = None, @@ -86,16 +84,16 @@ async def get_skill_list( filter = {} if query: - filter["query"] = query + filter['query'] = query if view_option: - filter["view_option"] = view_option + filter['view_option'] = view_option - if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL): + if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL): groups = Groups.get_groups_by_member_id(user.id, db=db) if groups: - filter["group_ids"] = [group.id for group in groups] + filter['group_ids'] = [group.id for group in groups] - filter["user_id"] = user.id + filter['user_id'] = user.id result = Skills.search_skills(user.id, filter=filter, skip=skip, limit=limit, db=db) @@ -104,13 +102,13 @@ async def get_skill_list( SkillAccessResponse( **skill.model_dump(), write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == skill.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="write", + permission='write', db=db, ) ), @@ -126,15 +124,15 @@ async def get_skill_list( ############################ -@router.get("/export", response_model=list[SkillModel]) +@router.get('/export', response_model=list[SkillModel]) async def export_skills( request: Request, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( + if user.role != 'admin' and not has_permission( user.id, - "workspace.skills", + 'workspace.skills', request.app.state.config.USER_PERMISSIONS, db=db, ): @@ -143,10 +141,10 @@ async def export_skills( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: return Skills.get_skills(db=db) else: - return Skills.get_skills_by_user_id(user.id, "read", db=db) + return Skills.get_skills_by_user_id(user.id, 'read', db=db) ############################ @@ -154,22 +152,22 @@ async def export_skills( ############################ -@router.post("/create", response_model=Optional[SkillResponse]) +@router.post('/create', response_model=Optional[SkillResponse]) async def create_new_skill( request: Request, form_data: SkillForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( - user.id, "workspace.skills", request.app.state.config.USER_PERMISSIONS, db=db + if user.role != 'admin' and not has_permission( + user.id, 'workspace.skills', request.app.state.config.USER_PERMISSIONS, db=db ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.UNAUTHORIZED, ) - form_data.id = form_data.id.lower().replace(" ", "-") + form_data.id = form_data.id.lower().replace(' ', '-') existing = Skills.get_skill_by_id(form_data.id, db=db) if existing is not None: @@ -185,10 +183,10 @@ async def create_new_skill( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error creating skill"), + detail=ERROR_MESSAGES.DEFAULT('Error creating skill'), ) except Exception as e: - log.exception(f"Failed to create skill: {e}") + log.exception(f'Failed to create skill: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), @@ -200,34 +198,32 @@ async def create_new_skill( ############################ -@router.get("/id/{id}", response_model=Optional[SkillAccessResponse]) -async def get_skill_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}', response_model=Optional[SkillAccessResponse]) +async def get_skill_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): skill = Skills.get_skill_by_id(id, db=db) if skill: if ( - user.role == "admin" + user.role == 'admin' or skill.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="read", + permission='read', db=db, ) ): return SkillAccessResponse( **skill.model_dump(), write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == skill.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="write", + permission='write', db=db, ) ), @@ -249,7 +245,7 @@ async def get_skill_by_id( ############################ -@router.post("/id/{id}/update", response_model=Optional[SkillModel]) +@router.post('/id/{id}/update', response_model=Optional[SkillModel]) async def update_skill_by_id( request: Request, id: str, @@ -268,12 +264,12 @@ async def update_skill_by_id( skill.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -282,7 +278,7 @@ async def update_skill_by_id( try: updated = { - **form_data.model_dump(exclude={"id"}), + **form_data.model_dump(exclude={'id'}), } skill = Skills.update_skill_by_id(id, updated, db=db) @@ -292,7 +288,7 @@ async def update_skill_by_id( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating skill"), + detail=ERROR_MESSAGES.DEFAULT('Error updating skill'), ) except Exception as e: raise HTTPException( @@ -310,7 +306,7 @@ class SkillAccessGrantsForm(BaseModel): access_grants: list[dict] -@router.post("/id/{id}/access/update", response_model=Optional[SkillModel]) +@router.post('/id/{id}/access/update', response_model=Optional[SkillModel]) async def update_skill_access_by_id( request: Request, id: str, @@ -329,12 +325,12 @@ async def update_skill_access_by_id( skill.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -346,10 +342,10 @@ async def update_skill_access_by_id( user.id, user.role, form_data.access_grants, - "sharing.public_skills", + 'sharing.public_skills', ) - AccessGrants.set_access_grants("skill", id, form_data.access_grants, db=db) + AccessGrants.set_access_grants('skill', id, form_data.access_grants, db=db) return Skills.get_skill_by_id(id, db=db) @@ -359,20 +355,18 @@ async def update_skill_access_by_id( ############################ -@router.post("/id/{id}/toggle", response_model=Optional[SkillModel]) -async def toggle_skill_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.post('/id/{id}/toggle', response_model=Optional[SkillModel]) +async def toggle_skill_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): skill = Skills.get_skill_by_id(id, db=db) if skill: if ( - user.role == "admin" + user.role == 'admin' or skill.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="write", + permission='write', db=db, ) ): @@ -383,7 +377,7 @@ async def toggle_skill_by_id( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error toggling skill"), + detail=ERROR_MESSAGES.DEFAULT('Error toggling skill'), ) else: raise HTTPException( @@ -402,7 +396,7 @@ async def toggle_skill_by_id( ############################ -@router.delete("/id/{id}/delete", response_model=bool) +@router.delete('/id/{id}/delete', response_model=bool) async def delete_skill_by_id( request: Request, id: str, @@ -420,12 +414,12 @@ async def delete_skill_by_id( skill.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index d322fca0b6..e509b5e644 100644 --- a/backend/open_webui/routers/tasks.py +++ b/backend/open_webui/routers/tasks.py @@ -54,36 +54,34 @@ 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) -): +@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} + return {'active_chat_ids': active} -@router.get("/config") +@router.get('/config') async def get_task_config(request: Request, user=Depends(get_verified_user)): return { - "TASK_MODEL": request.app.state.config.TASK_MODEL, - "TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL, - "TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, - "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, - "ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, - "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, - "TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, - "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE": request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE, - "ENABLE_FOLLOW_UP_GENERATION": request.app.state.config.ENABLE_FOLLOW_UP_GENERATION, - "ENABLE_TAGS_GENERATION": request.app.state.config.ENABLE_TAGS_GENERATION, - "ENABLE_TITLE_GENERATION": request.app.state.config.ENABLE_TITLE_GENERATION, - "ENABLE_SEARCH_QUERY_GENERATION": request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION, - "ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, - "QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, - "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, - "VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, + 'TASK_MODEL': request.app.state.config.TASK_MODEL, + 'TASK_MODEL_EXTERNAL': request.app.state.config.TASK_MODEL_EXTERNAL, + 'TITLE_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, + 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, + 'ENABLE_AUTOCOMPLETE_GENERATION': request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, + 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, + 'TAGS_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, + 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE, + 'ENABLE_FOLLOW_UP_GENERATION': request.app.state.config.ENABLE_FOLLOW_UP_GENERATION, + 'ENABLE_TAGS_GENERATION': request.app.state.config.ENABLE_TAGS_GENERATION, + 'ENABLE_TITLE_GENERATION': request.app.state.config.ENABLE_TITLE_GENERATION, + 'ENABLE_SEARCH_QUERY_GENERATION': request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION, + 'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, + 'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, + 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + 'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, } @@ -106,101 +104,75 @@ class TaskConfigForm(BaseModel): VOICE_MODE_PROMPT_TEMPLATE: Optional[str] -@router.post("/config/update") -async def update_task_config( - request: Request, form_data: TaskConfigForm, user=Depends(get_admin_user) -): +@router.post('/config/update') +async def update_task_config(request: Request, form_data: TaskConfigForm, user=Depends(get_admin_user)): request.app.state.config.TASK_MODEL = form_data.TASK_MODEL request.app.state.config.TASK_MODEL_EXTERNAL = form_data.TASK_MODEL_EXTERNAL request.app.state.config.ENABLE_TITLE_GENERATION = form_data.ENABLE_TITLE_GENERATION - request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = ( - form_data.TITLE_GENERATION_PROMPT_TEMPLATE - ) + request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = form_data.TITLE_GENERATION_PROMPT_TEMPLATE - request.app.state.config.ENABLE_FOLLOW_UP_GENERATION = ( - form_data.ENABLE_FOLLOW_UP_GENERATION - ) - request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = ( - form_data.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE - ) + request.app.state.config.ENABLE_FOLLOW_UP_GENERATION = form_data.ENABLE_FOLLOW_UP_GENERATION + request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE = form_data.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE - request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = ( - form_data.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE - ) + request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = form_data.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE - request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ( - form_data.ENABLE_AUTOCOMPLETE_GENERATION - ) + request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = form_data.ENABLE_AUTOCOMPLETE_GENERATION request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = ( form_data.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH ) - request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = ( - form_data.TAGS_GENERATION_PROMPT_TEMPLATE - ) + request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = form_data.TAGS_GENERATION_PROMPT_TEMPLATE request.app.state.config.ENABLE_TAGS_GENERATION = form_data.ENABLE_TAGS_GENERATION - request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION = ( - form_data.ENABLE_SEARCH_QUERY_GENERATION - ) - request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = ( - form_data.ENABLE_RETRIEVAL_QUERY_GENERATION - ) + request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION = form_data.ENABLE_SEARCH_QUERY_GENERATION + request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION = form_data.ENABLE_RETRIEVAL_QUERY_GENERATION - request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = ( - form_data.QUERY_GENERATION_PROMPT_TEMPLATE - ) - request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = ( - form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE - ) + request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = form_data.QUERY_GENERATION_PROMPT_TEMPLATE + request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE - request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = ( - form_data.VOICE_MODE_PROMPT_TEMPLATE - ) + request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = form_data.VOICE_MODE_PROMPT_TEMPLATE return { - "TASK_MODEL": request.app.state.config.TASK_MODEL, - "TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL, - "ENABLE_TITLE_GENERATION": request.app.state.config.ENABLE_TITLE_GENERATION, - "TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, - "IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, - "ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, - "AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, - "TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, - "ENABLE_TAGS_GENERATION": request.app.state.config.ENABLE_TAGS_GENERATION, - "ENABLE_FOLLOW_UP_GENERATION": request.app.state.config.ENABLE_FOLLOW_UP_GENERATION, - "FOLLOW_UP_GENERATION_PROMPT_TEMPLATE": request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE, - "ENABLE_SEARCH_QUERY_GENERATION": request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION, - "ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, - "QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, - "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, - "VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, + 'TASK_MODEL': request.app.state.config.TASK_MODEL, + 'TASK_MODEL_EXTERNAL': request.app.state.config.TASK_MODEL_EXTERNAL, + 'ENABLE_TITLE_GENERATION': request.app.state.config.ENABLE_TITLE_GENERATION, + 'TITLE_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, + 'IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE': request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE, + 'ENABLE_AUTOCOMPLETE_GENERATION': request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION, + 'AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH': request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH, + 'TAGS_GENERATION_PROMPT_TEMPLATE': request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE, + 'ENABLE_TAGS_GENERATION': request.app.state.config.ENABLE_TAGS_GENERATION, + 'ENABLE_FOLLOW_UP_GENERATION': request.app.state.config.ENABLE_FOLLOW_UP_GENERATION, + 'FOLLOW_UP_GENERATION_PROMPT_TEMPLATE': request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE, + 'ENABLE_SEARCH_QUERY_GENERATION': request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION, + 'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION, + 'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE, + 'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + 'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE, } -@router.post("/title/completions") -async def generate_title( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@router.post('/title/completions') +async def generate_title(request: Request, form_data: dict, user=Depends(get_verified_user)): check_credit_by_user_id(user_id=user.id, form_data=form_data) if not request.app.state.config.ENABLE_TITLE_GENERATION: return JSONResponse( status_code=status.HTTP_200_OK, - content={"detail": "Title generation is disabled"}, + content={'detail': 'Title generation is disabled'}, ) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Model not found", + detail='Model not found', ) # Check if the user has a custom task model @@ -212,37 +184,33 @@ async def generate_title( models, ) - log.debug( - f"generating chat title using model {task_model_id} for user {user.email} " - ) + log.debug(f'generating chat title using model {task_model_id} for user {user.email} ') - if request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE != "": + if request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE != '': template = request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE else: template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE - content = title_generation_template(template, form_data["messages"], user) + content = title_generation_template(template, form_data['messages'], user) - max_tokens = ( - models[task_model_id].get("info", {}).get("params", {}).get("max_tokens", 1000) - ) + max_tokens = models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000) payload = { - "model": task_model_id, - "messages": [{"role": "user", "content": content}], - "stream": False, + 'model': task_model_id, + 'messages': [{'role': 'user', 'content': content}], + 'stream': False, **( - {"max_tokens": max_tokens} - if models[task_model_id].get("owned_by") == "ollama" + {'max_tokens': max_tokens} + if models[task_model_id].get('owned_by') == 'ollama' else { - "max_completion_tokens": max_tokens, + 'max_completion_tokens': max_tokens, } ), - "metadata": { - **(request.state.metadata if hasattr(request.state, "metadata") else {}), - "task": str(TASKS.TITLE_GENERATION), - "task_body": form_data, - "chat_id": form_data.get("chat_id", None), + 'metadata': { + **(request.state.metadata if hasattr(request.state, 'metadata') else {}), + 'task': str(TASKS.TITLE_GENERATION), + 'task_body': form_data, + 'chat_id': form_data.get('chat_id', None), }, } @@ -255,37 +223,35 @@ async def generate_title( try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: - log.error("Exception occurred", exc_info=True) + log.error('Exception occurred', exc_info=True) return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": "An internal error has occurred."}, + content={'detail': 'An internal error has occurred.'}, ) -@router.post("/follow_up/completions") -async def generate_follow_ups( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@router.post('/follow_up/completions') +async def generate_follow_ups(request: Request, form_data: dict, user=Depends(get_verified_user)): check_credit_by_user_id(user_id=user.id, form_data=form_data) if not request.app.state.config.ENABLE_FOLLOW_UP_GENERATION: return JSONResponse( status_code=status.HTTP_200_OK, - content={"detail": "Follow-up generation is disabled"}, + content={'detail': 'Follow-up generation is disabled'}, ) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Model not found", + detail='Model not found', ) # Check if the user has a custom task model @@ -297,26 +263,24 @@ async def generate_follow_ups( models, ) - log.debug( - f"generating chat title using model {task_model_id} for user {user.email} " - ) + log.debug(f'generating chat title using model {task_model_id} for user {user.email} ') - if request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE != "": + if request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE != '': template = request.app.state.config.FOLLOW_UP_GENERATION_PROMPT_TEMPLATE else: template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE - content = follow_up_generation_template(template, form_data["messages"], user) + content = follow_up_generation_template(template, form_data['messages'], user) payload = { - "model": task_model_id, - "messages": [{"role": "user", "content": content}], - "stream": False, - "metadata": { - **(request.state.metadata if hasattr(request.state, "metadata") else {}), - "task": str(TASKS.FOLLOW_UP_GENERATION), - "task_body": form_data, - "chat_id": form_data.get("chat_id", None), + 'model': task_model_id, + 'messages': [{'role': 'user', 'content': content}], + 'stream': False, + 'metadata': { + **(request.state.metadata if hasattr(request.state, 'metadata') else {}), + 'task': str(TASKS.FOLLOW_UP_GENERATION), + 'task_body': form_data, + 'chat_id': form_data.get('chat_id', None), }, } @@ -329,37 +293,35 @@ async def generate_follow_ups( try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: - log.error("Exception occurred", exc_info=True) + log.error('Exception occurred', exc_info=True) return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": "An internal error has occurred."}, + content={'detail': 'An internal error has occurred.'}, ) -@router.post("/tags/completions") -async def generate_chat_tags( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@router.post('/tags/completions') +async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get_verified_user)): check_credit_by_user_id(user_id=user.id, form_data=form_data) if not request.app.state.config.ENABLE_TAGS_GENERATION: return JSONResponse( status_code=status.HTTP_200_OK, - content={"detail": "Tags generation is disabled"}, + content={'detail': 'Tags generation is disabled'}, ) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Model not found", + detail='Model not found', ) # Check if the user has a custom task model @@ -371,26 +333,24 @@ async def generate_chat_tags( models, ) - log.debug( - f"generating chat tags using model {task_model_id} for user {user.email} " - ) + log.debug(f'generating chat tags using model {task_model_id} for user {user.email} ') - if request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE != "": + if request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE != '': template = request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE else: template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE - content = tags_generation_template(template, form_data["messages"], user) + content = tags_generation_template(template, form_data['messages'], user) payload = { - "model": task_model_id, - "messages": [{"role": "user", "content": content}], - "stream": False, - "metadata": { - **(request.state.metadata if hasattr(request.state, "metadata") else {}), - "task": str(TASKS.TAGS_GENERATION), - "task_body": form_data, - "chat_id": form_data.get("chat_id", None), + 'model': task_model_id, + 'messages': [{'role': 'user', 'content': content}], + 'stream': False, + 'metadata': { + **(request.state.metadata if hasattr(request.state, 'metadata') else {}), + 'task': str(TASKS.TAGS_GENERATION), + 'task_body': form_data, + 'chat_id': form_data.get('chat_id', None), }, } @@ -403,31 +363,29 @@ async def generate_chat_tags( try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: - log.error(f"Error generating chat completion: {e}") + log.error(f'Error generating chat completion: {e}') return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "An internal error has occurred."}, + content={'detail': 'An internal error has occurred.'}, ) -@router.post("/image_prompt/completions") -async def generate_image_prompt( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@router.post('/image_prompt/completions') +async def generate_image_prompt(request: Request, form_data: dict, user=Depends(get_verified_user)): check_credit_by_user_id(user_id=user.id, form_data=form_data) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Model not found", + detail='Model not found', ) # Check if the user has a custom task model @@ -439,26 +397,24 @@ async def generate_image_prompt( models, ) - log.debug( - f"generating image prompt using model {task_model_id} for user {user.email} " - ) + log.debug(f'generating image prompt using model {task_model_id} for user {user.email} ') - if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != "": + if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != '': template = request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE else: template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE - content = image_prompt_generation_template(template, form_data["messages"], user) + content = image_prompt_generation_template(template, form_data['messages'], user) payload = { - "model": task_model_id, - "messages": [{"role": "user", "content": content}], - "stream": False, - "metadata": { - **(request.state.metadata if hasattr(request.state, "metadata") else {}), - "task": str(TASKS.IMAGE_PROMPT_GENERATION), - "task_body": form_data, - "chat_id": form_data.get("chat_id", None), + 'model': task_model_id, + 'messages': [{'role': 'user', 'content': content}], + 'stream': False, + 'metadata': { + **(request.state.metadata if hasattr(request.state, 'metadata') else {}), + 'task': str(TASKS.IMAGE_PROMPT_GENERATION), + 'task_body': form_data, + 'chat_id': form_data.get('chat_id', None), }, } @@ -471,49 +427,47 @@ async def generate_image_prompt( try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: - log.error("Exception occurred", exc_info=True) + log.error('Exception occurred', exc_info=True) return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": "An internal error has occurred."}, + content={'detail': 'An internal error has occurred.'}, ) -@router.post("/queries/completions") -async def generate_queries( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@router.post('/queries/completions') +async def generate_queries(request: Request, form_data: dict, user=Depends(get_verified_user)): check_credit_by_user_id(user_id=user.id, form_data=form_data) - type = form_data.get("type") - if type == "web_search": + type = form_data.get('type') + if type == 'web_search': if not request.app.state.config.ENABLE_SEARCH_QUERY_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Search query generation is disabled", + detail=f'Search query generation is disabled', ) - elif type == "retrieval": + elif type == 'retrieval': if not request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Query generation is disabled", + detail=f'Query generation is disabled', ) - if getattr(request.state, "cached_queries", None): - log.info(f"Reusing cached queries: {request.state.cached_queries}") + if getattr(request.state, 'cached_queries', None): + log.info(f'Reusing cached queries: {request.state.cached_queries}') return request.state.cached_queries - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Model not found", + detail='Model not found', ) # Check if the user has a custom task model @@ -525,26 +479,24 @@ async def generate_queries( models, ) - log.debug( - f"generating {type} queries using model {task_model_id} for user {user.email}" - ) + log.debug(f'generating {type} queries using model {task_model_id} for user {user.email}') - if (request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE).strip() != "": + if (request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE).strip() != '': template = request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE else: template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE - content = query_generation_template(template, form_data["messages"], user) + content = query_generation_template(template, form_data['messages'], user) payload = { - "model": task_model_id, - "messages": [{"role": "user", "content": content}], - "stream": False, - "metadata": { - **(request.state.metadata if hasattr(request.state, "metadata") else {}), - "task": str(TASKS.QUERY_GENERATION), - "task_body": form_data, - "chat_id": form_data.get("chat_id", None), + 'model': task_model_id, + 'messages': [{'role': 'user', 'content': content}], + 'stream': False, + 'metadata': { + **(request.state.metadata if hasattr(request.state, 'metadata') else {}), + 'task': str(TASKS.QUERY_GENERATION), + 'task_body': form_data, + 'chat_id': form_data.get('chat_id', None), }, } @@ -559,48 +511,43 @@ async def generate_queries( except Exception as e: return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": str(e)}, + content={'detail': str(e)}, ) -@router.post("/auto/completions") -async def generate_autocompletion( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@router.post('/auto/completions') +async def generate_autocompletion(request: Request, form_data: dict, user=Depends(get_verified_user)): check_credit_by_user_id(user_id=user.id, form_data=form_data) if not request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Autocompletion generation is disabled", + detail=f'Autocompletion generation is disabled', ) - type = form_data.get("type") - prompt = form_data.get("prompt") - messages = form_data.get("messages") + type = form_data.get('type') + prompt = form_data.get('prompt') + messages = form_data.get('messages') if request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH > 0: - if ( - len(prompt) - > request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH - ): + if len(prompt) > request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Input prompt exceeds maximum length of {request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}", + detail=f'Input prompt exceeds maximum length of {request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH}', ) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Model not found", + detail='Model not found', ) # Check if the user has a custom task model @@ -612,11 +559,9 @@ async def generate_autocompletion( models, ) - log.debug( - f"generating autocompletion using model {task_model_id} for user {user.email}" - ) + log.debug(f'generating autocompletion using model {task_model_id} for user {user.email}') - if (request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE).strip() != "": + if (request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE).strip() != '': template = request.app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE else: template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE @@ -624,14 +569,14 @@ async def generate_autocompletion( content = autocomplete_generation_template(template, prompt, messages, type, user) payload = { - "model": task_model_id, - "messages": [{"role": "user", "content": content}], - "stream": False, - "metadata": { - **(request.state.metadata if hasattr(request.state, "metadata") else {}), - "task": str(TASKS.AUTOCOMPLETE_GENERATION), - "task_body": form_data, - "chat_id": form_data.get("chat_id", None), + 'model': task_model_id, + 'messages': [{'role': 'user', 'content': content}], + 'stream': False, + 'metadata': { + **(request.state.metadata if hasattr(request.state, 'metadata') else {}), + 'task': str(TASKS.AUTOCOMPLETE_GENERATION), + 'task_body': form_data, + 'chat_id': form_data.get('chat_id', None), }, } @@ -644,31 +589,29 @@ async def generate_autocompletion( try: return await generate_chat_completion(request, form_data=payload, user=user) except Exception as e: - log.error(f"Error generating chat completion: {e}") + log.error(f'Error generating chat completion: {e}') return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "An internal error has occurred."}, + content={'detail': 'An internal error has occurred.'}, ) -@router.post("/emoji/completions") -async def generate_emoji( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@router.post('/emoji/completions') +async def generate_emoji(request: Request, form_data: dict, user=Depends(get_verified_user)): check_credit_by_user_id(user_id=user.id, form_data=form_data) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Model not found", + detail='Model not found', ) # Check if the user has a custom task model @@ -680,28 +623,28 @@ async def generate_emoji( models, ) - log.debug(f"generating emoji using model {task_model_id} for user {user.email} ") + log.debug(f'generating emoji using model {task_model_id} for user {user.email} ') template = DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE - content = emoji_generation_template(template, form_data["prompt"], user) + content = emoji_generation_template(template, form_data['prompt'], user) payload = { - "model": task_model_id, - "messages": [{"role": "user", "content": content}], - "stream": False, + 'model': task_model_id, + 'messages': [{'role': 'user', 'content': content}], + 'stream': False, **( - {"max_tokens": 4} - if models[task_model_id].get("owned_by") == "ollama" + {'max_tokens': 4} + if models[task_model_id].get('owned_by') == 'ollama' else { - "max_completion_tokens": 4, + 'max_completion_tokens': 4, } ), - "metadata": { - **(request.state.metadata if hasattr(request.state, "metadata") else {}), - "task": str(TASKS.EMOJI_GENERATION), - "task_body": form_data, - "chat_id": form_data.get("chat_id", None), + 'metadata': { + **(request.state.metadata if hasattr(request.state, 'metadata') else {}), + 'task': str(TASKS.EMOJI_GENERATION), + 'task_body': form_data, + 'chat_id': form_data.get('chat_id', None), }, } @@ -716,48 +659,46 @@ async def generate_emoji( except Exception as e: return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": str(e)}, + content={'detail': str(e)}, ) -@router.post("/moa/completions") -async def generate_moa_response( - request: Request, form_data: dict, user=Depends(get_verified_user) -): +@router.post('/moa/completions') +async def generate_moa_response(request: Request, form_data: dict, user=Depends(get_verified_user)): check_credit_by_user_id(user_id=user.id, form_data=form_data) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Model not found", + detail='Model not found', ) template = DEFAULT_MOA_GENERATION_PROMPT_TEMPLATE content = moa_response_generation_template( template, - form_data["prompt"], - form_data["responses"], + form_data['prompt'], + form_data['responses'], ) payload = { - "model": model_id, - "messages": [{"role": "user", "content": content}], - "stream": form_data.get("stream", False), - "metadata": { - **(request.state.metadata if hasattr(request.state, "metadata") else {}), - "chat_id": form_data.get("chat_id", None), - "task": str(TASKS.MOA_RESPONSE_GENERATION), - "task_body": form_data, + 'model': model_id, + 'messages': [{'role': 'user', 'content': content}], + 'stream': form_data.get('stream', False), + 'metadata': { + **(request.state.metadata if hasattr(request.state, 'metadata') else {}), + 'chat_id': form_data.get('chat_id', None), + 'task': str(TASKS.MOA_RESPONSE_GENERATION), + 'task_body': form_data, }, } @@ -772,5 +713,5 @@ async def generate_moa_response( except Exception as e: return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, - content={"detail": str(e)}, + content={'detail': str(e)}, ) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index fe03816865..59f1f3ab48 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -6,6 +6,8 @@ """ import logging +import posixpath +from urllib.parse import unquote import aiohttp from fastapi import APIRouter, Depends, Request, Response, WebSocket @@ -21,13 +23,32 @@ router = APIRouter() -STREAMING_CONTENT_TYPES = ("application/octet-stream", "image/", "application/pdf") -STRIPPED_RESPONSE_HEADERS = frozenset( - ("transfer-encoding", "connection", "content-encoding", "content-length") -) +STREAMING_CONTENT_TYPES = ('application/octet-stream', 'image/', 'application/pdf') +STRIPPED_RESPONSE_HEADERS = frozenset(('transfer-encoding', 'connection', 'content-encoding', 'content-length')) -@router.get("/") +def _sanitize_proxy_path(path: str) -> str | None: + """Sanitize a proxy path to prevent directory traversal / SSRF. + + Returns the cleaned path, or None if the path is invalid. + Trailing slashes are preserved โ€” many upstream frameworks treat + ``/path`` and ``/path/`` differently. + """ + decoded = unquote(path) + had_trailing_slash = decoded.endswith('/') + normalized = posixpath.normpath(decoded) + # Remove any leading slashes that would reset the base + cleaned = normalized.lstrip('/') + # Reject if normpath resolved to parent traversal or current-dir only + if cleaned.startswith('..') or cleaned == '.': + return None + # Restore trailing slash if the original path had one + if had_trailing_slash and cleaned and not cleaned.endswith('/'): + cleaned += '/' + return cleaned + + +@router.get('/') async def list_terminal_servers(request: Request, user=Depends(get_verified_user)): """Return terminal servers the authenticated user has access to.""" connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] @@ -35,20 +56,19 @@ async def list_terminal_servers(request: Request, user=Depends(get_verified_user return [ { - "id": connection.get("id", ""), - "url": connection.get("url", ""), - "name": connection.get("name", ""), + 'id': connection.get('id', ''), + 'url': connection.get('url', ''), + 'name': connection.get('name', ''), } for connection in connections - if connection.get("enabled", True) - and has_connection_access(user, connection, user_group_ids) + if connection.get('enabled', True) and has_connection_access(user, connection, user_group_ids) ] -PROXY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] +PROXY_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] -@router.api_route("/{server_id}/{path:path}", methods=PROXY_METHODS) +@router.api_route('/{server_id}/{path:path}', methods=PROXY_METHODS) async def proxy_terminal( server_id: str, path: str, @@ -57,46 +77,52 @@ async def proxy_terminal( ): """Proxy a request to the admin terminal server identified by *server_id*.""" connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] - connection = next((c for c in connections if c.get("id") == server_id), None) + connection = next((c for c in connections if c.get('id') == server_id), None) if connection is None: - return JSONResponse( - {"error": f"Terminal server '{server_id}' not found"}, status_code=404 - ) + return JSONResponse({'error': f"Terminal server '{server_id}' not found"}, status_code=404) user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} if not has_connection_access(user, connection, user_group_ids): - return JSONResponse({"error": "Access denied"}, status_code=403) + return JSONResponse({'error': 'Access denied'}, status_code=403) - base_url = (connection.get("url") or "").rstrip("/") + base_url = (connection.get('url') or '').rstrip('/') if not base_url: - return JSONResponse( - {"error": "Terminal server URL not configured"}, status_code=503 - ) + return JSONResponse({'error': 'Terminal server URL not configured'}, status_code=503) + + safe_path = _sanitize_proxy_path(path) + if safe_path is None: + return JSONResponse({'error': 'Invalid path'}, status_code=400) + + 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}' - target_url = f"{base_url}/{path}" if request.query_params: - target_url += f"?{request.query_params}" + target_url += f'?{request.query_params}' - headers = {"X-User-Id": user.id} + headers = {'X-User-Id': user.id} cookies = {} - auth_type = connection.get("auth_type", "bearer") + auth_type = connection.get('auth_type', 'bearer') - if auth_type == "bearer": - headers["Authorization"] = f"Bearer {connection.get('key', '')}" - elif auth_type == "session": + if auth_type == 'bearer': + headers['Authorization'] = f'Bearer {connection.get("key", "")}' + elif auth_type == 'session': cookies = request.cookies - headers["Authorization"] = f"Bearer {request.state.token.credentials}" - elif auth_type == "system_oauth": + headers['Authorization'] = f'Bearer {request.state.token.credentials}' + elif auth_type == 'system_oauth': cookies = request.cookies - oauth_token = request.headers.get("x-oauth-access-token", "") + oauth_token = request.headers.get('x-oauth-access-token', '') if oauth_token: - headers["Authorization"] = f"Bearer {oauth_token}" + headers['Authorization'] = f'Bearer {oauth_token}' # auth_type == "none": no Authorization header - content_type = request.headers.get("content-type") + content_type = request.headers.get('content-type') if content_type: - headers["Content-Type"] = content_type + headers['Content-Type'] = content_type body = await request.body() session = aiohttp.ClientSession( @@ -113,7 +139,7 @@ async def proxy_terminal( data=body or None, ) - upstream_content_type = upstream_response.headers.get("content-type", "") + upstream_content_type = upstream_response.headers.get('content-type', '') filtered_headers = { key: value for key, value in upstream_response.headers.items() @@ -140,16 +166,12 @@ async def cleanup(): await upstream_response.release() await session.close() - return Response( - content=response_body, status_code=status_code, headers=filtered_headers - ) + return Response(content=response_body, status_code=status_code, headers=filtered_headers) except Exception as error: await session.close() - log.exception("Terminal proxy error: %s", error) - return JSONResponse( - {"error": f"Terminal proxy error: {error}"}, status_code=502 - ) + log.exception('Terminal proxy error: %s', error) + return JSONResponse({'error': f'Terminal proxy error: {error}'}, status_code=502) # --------------------------------------------------------------------------- @@ -174,42 +196,42 @@ async def _resolve_authenticated_connection(ws: WebSocket, server_id: str): try: raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) payload = json.loads(raw) - if payload.get("type") != "auth": - await ws.close(code=4001, reason="Expected auth message") + if payload.get('type') != 'auth': + await ws.close(code=4001, reason='Expected auth message') return None - token = payload.get("token", "") + token = payload.get('token', '') data = decode_token(token) - if data is None or "id" not in data: - await ws.close(code=4001, reason="Invalid token") + if data is None or 'id' not in data: + await ws.close(code=4001, reason='Invalid token') return None - user = Users.get_user_by_id(data["id"]) + user = Users.get_user_by_id(data['id']) if user is None: - await ws.close(code=4001, reason="User not found") + await ws.close(code=4001, reason='User not found') return None except (asyncio.TimeoutError, json.JSONDecodeError): - await ws.close(code=4001, reason="Auth timeout or invalid payload") + await ws.close(code=4001, reason='Auth timeout or invalid payload') return None except Exception: - await ws.close(code=4001, reason="Invalid token") + await ws.close(code=4001, reason='Invalid token') return None # Resolve terminal server connections = ws.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] - connection = next((c for c in connections if c.get("id") == server_id), None) + connection = next((c for c in connections if c.get('id') == server_id), None) if connection is None: - await ws.close(code=4004, reason="Terminal server not found") + await ws.close(code=4004, reason='Terminal server not found') return None user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} if not has_connection_access(user, connection, user_group_ids): - await ws.close(code=4003, reason="Access denied") + await ws.close(code=4003, reason='Access denied') return None return user, connection -@router.websocket("/{server_id}/api/terminals/{session_id}") +@router.websocket('/{server_id}/api/terminals/{session_id}') async def ws_terminal( ws: WebSocket, server_id: str, @@ -228,24 +250,28 @@ async def ws_terminal( return user, connection = result - base_url = (connection.get("url") or "").rstrip("/") + base_url = (connection.get('url') or '').rstrip('/') if not base_url: - await ws.close(code=4003, reason="Terminal server URL not configured") + await ws.close(code=4003, reason='Terminal server URL not configured') return # Build upstream WebSocket URL (no token in URL) - ws_base = base_url.replace("https://", "wss://").replace("http://", "ws://") + ws_base = base_url.replace('https://', 'wss://').replace('http://', 'ws://') - auth_type = connection.get("auth_type", "bearer") + # 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 + upstream_params['user_id'] = user.id import urllib.parse - upstream_url = f"{ws_base}/api/terminals/{session_id}" + if policy_id: + upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{session_id}' + else: + upstream_url = f'{ws_base}/api/terminals/{session_id}' if upstream_params: - upstream_url += f"?{urllib.parse.urlencode(upstream_params)}" + upstream_url += f'?{urllib.parse.urlencode(upstream_params)}' session = aiohttp.ClientSession() try: @@ -254,22 +280,22 @@ async def ws_terminal( import json as _json # First-message auth to upstream terminal server - auth_type = connection.get("auth_type", "bearer") - if auth_type == "bearer": - key = connection.get("key", "") - await upstream.send_str(_json.dumps({"type": "auth", "token": key})) + auth_type = connection.get('auth_type', 'bearer') + if auth_type == 'bearer': + key = connection.get('key', '') + await upstream.send_str(_json.dumps({'type': 'auth', 'token': key})) async def _client_to_upstream(): """Forward client โ†’ upstream.""" try: while True: msg = await ws.receive() - if msg["type"] == "websocket.disconnect": + if msg['type'] == 'websocket.disconnect': break - elif "bytes" in msg and msg["bytes"]: - await upstream.send_bytes(msg["bytes"]) - elif "text" in msg and msg["text"]: - await upstream.send_str(msg["text"]) + elif 'bytes' in msg and msg['bytes']: + await upstream.send_bytes(msg['bytes']) + elif 'text' in msg and msg['text']: + await upstream.send_str(msg['text']) except Exception: pass @@ -295,7 +321,7 @@ async def _upstream_to_client(): return_exceptions=True, ) except Exception as e: - log.exception("Terminal WebSocket proxy error: %s", e) + log.exception('Terminal WebSocket proxy error: %s', e) finally: await session.close() try: diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 351c491bdc..a0b8bccd44 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -56,10 +56,12 @@ def get_tool_module(request, tool_id, load_from_db=True): ############################ # GetTools +# The danger is not in having tools, but in reaching +# for the wrong one. Let the choice here be deliberate. ############################ -@router.get("/", response_model=list[ToolUserResponse]) +@router.get('/', response_model=list[ToolUserResponse]) async def get_tools( request: Request, user=Depends(get_verified_user), @@ -69,18 +71,12 @@ async def get_tools( # Local Tools for tool in Tools.get_tools(defer_content=True, db=db): - tool_module = ( - request.app.state.TOOLS.get(tool.id) - if hasattr(request.app.state, "TOOLS") - else None - ) + tool_module = request.app.state.TOOLS.get(tool.id) if hasattr(request.app.state, 'TOOLS') else None tools.append( ToolUserResponse( **{ **tool.model_dump(), - "has_user_valves": ( - hasattr(tool_module, "UserValves") if tool_module else False - ), + 'has_user_valves': (hasattr(tool_module, 'UserValves') if tool_module else False), } ) ) @@ -88,88 +84,82 @@ async def get_tools( # OpenAPI Tool Servers server_access_grants = {} for server in await get_tool_servers(request): - connection = request.app.state.config.TOOL_SERVER_CONNECTIONS[ - server.get("idx", 0) - ] - server_config = connection.get("config", {}) + server_idx = server.get('idx', 0) + connections = request.app.state.config.TOOL_SERVER_CONNECTIONS + if server_idx >= len(connections): + log.warning( + f'Tool server index {server_idx} out of range ' + f'(have {len(connections)} connections), skipping server {server.get("id")}' + ) + continue + connection = connections[server_idx] + server_config = connection.get('config', {}) - server_id = f"server:{server.get('id')}" - server_access_grants[server_id] = server_config.get("access_grants", []) + server_id = f'server:{server.get("id")}' + server_access_grants[server_id] = server_config.get('access_grants', []) tools.append( ToolUserResponse( **{ - "id": server_id, - "user_id": server_id, - "name": server.get("openapi", {}) - .get("info", {}) - .get("title", "Tool Server"), - "meta": { - "description": server.get("openapi", {}) - .get("info", {}) - .get("description", ""), + 'id': server_id, + 'user_id': server_id, + 'name': server.get('openapi', {}).get('info', {}).get('title', 'Tool Server'), + 'meta': { + 'description': server.get('openapi', {}).get('info', {}).get('description', ''), }, - "updated_at": int(time.time()), - "created_at": int(time.time()), + 'updated_at': int(time.time()), + 'created_at': int(time.time()), } ) ) # MCP Tool Servers for server in request.app.state.config.TOOL_SERVER_CONNECTIONS: - if server.get("type", "openapi") == "mcp" and server.get("config", {}).get( - "enable" - ): - server_id = server.get("info", {}).get("id") - auth_type = server.get("auth_type", "none") + if server.get('type', 'openapi') == 'mcp' and server.get('config', {}).get('enable'): + server_id = server.get('info', {}).get('id') + auth_type = server.get('auth_type', 'none') session_token = None - if auth_type == "oauth_2.1": - splits = server_id.split(":") + if auth_type == 'oauth_2.1': + splits = server_id.split(':') server_id = splits[-1] if len(splits) > 1 else server_id - session_token = ( - await request.app.state.oauth_client_manager.get_oauth_token( - user.id, f"mcp:{server_id}" - ) + session_token = await request.app.state.oauth_client_manager.get_oauth_token( + user.id, f'mcp:{server_id}' ) - server_config = server.get("config", {}) + server_config = server.get('config', {}) - tool_id = f"server:mcp:{server.get('info', {}).get('id')}" - server_access_grants[tool_id] = server_config.get("access_grants", []) + tool_id = f'server:mcp:{server.get("info", {}).get("id")}' + server_access_grants[tool_id] = server_config.get('access_grants', []) tools.append( ToolUserResponse( **{ - "id": tool_id, - "user_id": tool_id, - "name": server.get("info", {}).get("name", "MCP Tool Server"), - "meta": { - "description": server.get("info", {}).get( - "description", "" - ), + 'id': tool_id, + 'user_id': tool_id, + 'name': server.get('info', {}).get('name', 'MCP Tool Server'), + 'meta': { + 'description': server.get('info', {}).get('description', ''), }, - "updated_at": int(time.time()), - "created_at": int(time.time()), + 'updated_at': int(time.time()), + 'created_at': int(time.time()), **( { - "authenticated": session_token is not None, + 'authenticated': session_token is not None, } - if auth_type == "oauth_2.1" + if auth_type == 'oauth_2.1' else {} ), } ) ) - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: # Admin can see all tools return tools else: - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} tools = [ tool for tool in tools @@ -177,17 +167,17 @@ async def get_tools( or ( has_access( user.id, - "read", + 'read', server_access_grants.get(str(tool.id), []), user_group_ids, db=db, ) - if str(tool.id).startswith("server:") + if str(tool.id).startswith('server:') else AccessGrants.has_access( user_id=user.id, - resource_type="tool", + resource_type='tool', resource_id=tool.id, - permission="read", + permission='read', user_group_ids=user_group_ids, db=db, ) @@ -201,34 +191,25 @@ async def get_tools( ############################ -@router.get("/list", response_model=list[ToolAccessResponse]) -async def get_tool_list( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: +@router.get('/list', response_model=list[ToolAccessResponse]) +async def get_tool_list(user=Depends(get_verified_user), db: Session = Depends(get_session)): + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: tools = Tools.get_tools(defer_content=True, db=db) else: - tools = Tools.get_tools_by_user_id(user.id, "read", defer_content=True, db=db) + tools = Tools.get_tools_by_user_id(user.id, 'read', defer_content=True, db=db) - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} result = [] for tool in tools: has_write = ( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == tool.user_id or any( - g.permission == "write" + g.permission == 'write' and ( - ( - g.principal_type == "user" - and (g.principal_id == user.id or g.principal_id == "*") - ) - or ( - g.principal_type == "group" and g.principal_id in user_group_ids - ) + (g.principal_type == 'user' and (g.principal_id == user.id or g.principal_id == '*')) + or (g.principal_type == 'group' and g.principal_id in user_group_ids) ) for g in tool.access_grants ) @@ -253,70 +234,59 @@ class LoadUrlForm(BaseModel): def github_url_to_raw_url(url: str) -> str: # Handle 'tree' (folder) URLs (add main.py at the end) - m1 = re.match(r"https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)", url) + m1 = re.match(r'https://github\.com/([^/]+)/([^/]+)/tree/([^/]+)/(.*)', url) if m1: org, repo, branch, path = m1.groups() - return f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip('/')}/main.py" + return f'https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path.rstrip("/")}/main.py' # Handle 'blob' (file) URLs - m2 = re.match(r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)", url) + m2 = re.match(r'https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.*)', url) if m2: org, repo, branch, path = m2.groups() - return ( - f"https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}" - ) + return f'https://raw.githubusercontent.com/{org}/{repo}/refs/heads/{branch}/{path}' # No match; return as-is return url -@router.post("/load/url", response_model=Optional[dict]) -async def load_tool_from_url( - request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user) -): +@router.post('/load/url', response_model=Optional[dict]) +async def load_tool_from_url(request: Request, form_data: LoadUrlForm, user=Depends(get_admin_user)): # NOTE: This is NOT a SSRF vulnerability: # This endpoint is admin-only (see get_admin_user), meant for *trusted* internal use, # and does NOT accept untrusted user input. Access is enforced by authentication. url = str(form_data.url) if not url: - raise HTTPException(status_code=400, detail="Please enter a valid URL") + raise HTTPException(status_code=400, detail='Please enter a valid URL') url = github_url_to_raw_url(url) - url_parts = url.rstrip("/").split("/") + url_parts = url.rstrip('/').split('/') file_name = url_parts[-1] tool_name = ( file_name[:-3] - if ( - file_name.endswith(".py") - and (not file_name.startswith(("main.py", "index.py", "__init__.py"))) - ) - else url_parts[-2] if len(url_parts) > 1 else "function" + if (file_name.endswith('.py') and (not file_name.startswith(('main.py', 'index.py', '__init__.py')))) + else url_parts[-2] + if len(url_parts) > 1 + else 'function' ) try: async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: - async with session.get( - url, headers={"Content-Type": "application/json"} - ) as resp: + async with session.get(url, headers={'Content-Type': 'application/json'}) as resp: if resp.status != 200: - raise HTTPException( - status_code=resp.status, detail="Failed to fetch the tool" - ) + raise HTTPException(status_code=resp.status, detail='Failed to fetch the tool') data = await resp.text() if not data: - raise HTTPException( - status_code=400, detail="No data received from the URL" - ) + raise HTTPException(status_code=400, detail='No data received from the URL') return { - "name": tool_name, - "content": data, + 'name': tool_name, + 'content': data, } except Exception as e: - raise HTTPException(status_code=500, detail=f"Error importing tool: {e}") + raise HTTPException(status_code=500, detail=f'Error importing tool: {e}') ############################ @@ -324,15 +294,15 @@ async def load_tool_from_url( ############################ -@router.get("/export", response_model=list[ToolModel]) +@router.get('/export', response_model=list[ToolModel]) async def export_tools( request: Request, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not has_permission( + if user.role != 'admin' and not has_permission( user.id, - "workspace.tools_export", + 'workspace.tools_export', request.app.state.config.USER_PERMISSIONS, db=db, ): @@ -341,10 +311,10 @@ async def export_tools( detail=ERROR_MESSAGES.UNAUTHORIZED, ) - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: return Tools.get_tools(db=db) else: - return Tools.get_tools_by_user_id(user.id, "read", db=db) + return Tools.get_tools_by_user_id(user.id, 'read', db=db) ############################ @@ -352,20 +322,18 @@ async def export_tools( ############################ -@router.post("/create", response_model=Optional[ToolResponse]) +@router.post('/create', response_model=Optional[ToolResponse]) async def create_new_tools( request: Request, form_data: ToolForm, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - if user.role != "admin" and not ( - has_permission( - user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS, db=db - ) + if user.role != 'admin' and not ( + has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db) or has_permission( user.id, - "workspace.tools_import", + 'workspace.tools_import', request.app.state.config.USER_PERMISSIONS, db=db, ) @@ -378,7 +346,7 @@ async def create_new_tools( if not form_data.id.isidentifier(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Only alphanumeric characters and underscores are allowed in the id", + detail='Only alphanumeric characters and underscores are allowed in the id', ) form_data.id = form_data.id.lower() @@ -387,9 +355,7 @@ async def create_new_tools( if tools is None: try: form_data.content = replace_imports(form_data.content) - tool_module, frontmatter = load_tool_module_by_id( - form_data.id, content=form_data.content - ) + tool_module, frontmatter = load_tool_module_by_id(form_data.id, content=form_data.content) form_data.meta.manifest = frontmatter TOOLS = request.app.state.TOOLS @@ -398,7 +364,7 @@ async def create_new_tools( specs = get_tool_specs(TOOLS[form_data.id]) tools = Tools.insert_new_tool(user.id, form_data, specs, db=db) - tool_cache_dir = CACHE_DIR / "tools" / form_data.id + tool_cache_dir = CACHE_DIR / 'tools' / form_data.id tool_cache_dir.mkdir(parents=True, exist_ok=True) if tools: @@ -406,10 +372,10 @@ async def create_new_tools( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error creating tools"), + detail=ERROR_MESSAGES.DEFAULT('Error creating tools'), ) except Exception as e: - log.exception(f"Failed to load the tool by id {form_data.id}: {e}") + log.exception(f'Failed to load the tool by id {form_data.id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), @@ -426,34 +392,32 @@ async def create_new_tools( ############################ -@router.get("/id/{id}", response_model=Optional[ToolAccessResponse]) -async def get_tools_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}', response_model=Optional[ToolAccessResponse]) +async def get_tools_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): tools = Tools.get_tool_by_id(id, db=db) if tools: if ( - user.role == "admin" + user.role == 'admin' or tools.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="tool", + resource_type='tool', resource_id=tools.id, - permission="read", + permission='read', db=db, ) ): return ToolAccessResponse( **tools.model_dump(), write_access=( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) or user.id == tools.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="tool", + resource_type='tool', resource_id=tools.id, - permission="write", + permission='write', db=db, ) ), @@ -475,7 +439,7 @@ async def get_tools_by_id( ############################ -@router.post("/id/{id}/update", response_model=Optional[ToolModel]) +@router.post('/id/{id}/update', response_model=Optional[ToolModel]) async def update_tools_by_id( request: Request, id: str, @@ -495,12 +459,12 @@ async def update_tools_by_id( tools.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="tool", + resource_type='tool', resource_id=tools.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -518,8 +482,8 @@ async def update_tools_by_id( specs = get_tool_specs(TOOLS[id]) updated = { - **form_data.model_dump(exclude={"id"}), - "specs": specs, + **form_data.model_dump(exclude={'id'}), + 'specs': specs, } log.debug(updated) @@ -530,7 +494,7 @@ async def update_tools_by_id( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT("Error updating tools"), + detail=ERROR_MESSAGES.DEFAULT('Error updating tools'), ) except Exception as e: @@ -549,7 +513,7 @@ class ToolAccessGrantsForm(BaseModel): access_grants: list[dict] -@router.post("/id/{id}/access/update", response_model=Optional[ToolModel]) +@router.post('/id/{id}/access/update', response_model=Optional[ToolModel]) async def update_tool_access_by_id( request: Request, id: str, @@ -568,12 +532,12 @@ async def update_tool_access_by_id( tools.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="tool", + resource_type='tool', resource_id=tools.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -585,10 +549,10 @@ async def update_tool_access_by_id( user.id, user.role, form_data.access_grants, - "sharing.public_tools", + 'sharing.public_tools', ) - AccessGrants.set_access_grants("tool", id, form_data.access_grants, db=db) + AccessGrants.set_access_grants('tool', id, form_data.access_grants, db=db) return Tools.get_tool_by_id(id, db=db) @@ -598,7 +562,7 @@ async def update_tool_access_by_id( ############################ -@router.delete("/id/{id}/delete", response_model=bool) +@router.delete('/id/{id}/delete', response_model=bool) async def delete_tools_by_id( request: Request, id: str, @@ -616,12 +580,12 @@ async def delete_tools_by_id( tools.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="tool", + resource_type='tool', resource_id=tools.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -642,33 +606,47 @@ async def delete_tools_by_id( ############################ -@router.get("/id/{id}/valves", response_model=Optional[dict]) -async def get_tools_valves_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}/valves', response_model=Optional[dict]) +async def get_tools_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): tools = Tools.get_tool_by_id(id, db=db) - if tools: - try: - valves = Tools.get_tool_valves_by_id(id, db=db) - return valves - except Exception as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(str(e)), - ) - else: + if not tools: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='write', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + try: + valves = Tools.get_tool_valves_by_id(id, db=db) + return valves + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + ############################ # GetToolValvesSpec ############################ -@router.get("/id/{id}/valves/spec", response_model=Optional[dict]) +@router.get('/id/{id}/valves/spec', response_model=Optional[dict]) async def get_tools_valves_spec_by_id( request: Request, id: str, @@ -676,33 +654,49 @@ async def get_tools_valves_spec_by_id( db: Session = Depends(get_session), ): tools = Tools.get_tool_by_id(id, db=db) - if tools: - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module - - if hasattr(tools_module, "Valves"): - Valves = tools_module.Valves - schema = Valves.schema() - # Resolve dynamic options for select dropdowns - schema = resolve_valves_schema_options(Valves, schema, user) - return schema - return None - else: + if not tools: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='write', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if id in request.app.state.TOOLS: + tools_module = request.app.state.TOOLS[id] + else: + tools_module, _ = load_tool_module_by_id(id) + request.app.state.TOOLS[id] = tools_module + + if hasattr(tools_module, 'Valves'): + Valves = tools_module.Valves + schema = Valves.schema() + # Resolve dynamic options for select dropdowns + schema = resolve_valves_schema_options(Valves, schema, user) + return schema + return None + ############################ # UpdateToolValves ############################ -@router.post("/id/{id}/valves/update", response_model=Optional[dict]) +@router.post('/id/{id}/valves/update', response_model=Optional[dict]) async def update_tools_valves_by_id( request: Request, id: str, @@ -721,12 +715,12 @@ async def update_tools_valves_by_id( tools.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="tool", + resource_type='tool', resource_id=tools.id, - permission="write", + permission='write', db=db, ) - and user.role != "admin" + and user.role != 'admin' ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -739,7 +733,7 @@ async def update_tools_valves_by_id( tools_module, _ = load_tool_module_by_id(id) request.app.state.TOOLS[id] = tools_module - if not hasattr(tools_module, "Valves"): + if not hasattr(tools_module, 'Valves'): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND, @@ -753,7 +747,7 @@ async def update_tools_valves_by_id( Tools.update_tool_valves_by_id(id, valves_dict, db=db) return valves_dict except Exception as e: - log.exception(f"Failed to update tool valves by id {id}: {e}") + log.exception(f'Failed to update tool valves by id {id}: {e}') raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT(str(e)), @@ -765,28 +759,42 @@ async def update_tools_valves_by_id( ############################ -@router.get("/id/{id}/valves/user", response_model=Optional[dict]) -async def get_tools_user_valves_by_id( - id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/id/{id}/valves/user', response_model=Optional[dict]) +async def get_tools_user_valves_by_id(id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): tools = Tools.get_tool_by_id(id, db=db) - if tools: - try: - user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db) - return user_valves - except Exception as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(str(e)), - ) - else: + if not tools: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + try: + user_valves = Tools.get_user_valves_by_id_and_user_id(id, user.id, db=db) + return user_valves + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + -@router.get("/id/{id}/valves/user/spec", response_model=Optional[dict]) +@router.get('/id/{id}/valves/user/spec', response_model=Optional[dict]) async def get_tools_user_valves_spec_by_id( request: Request, id: str, @@ -794,28 +802,44 @@ async def get_tools_user_valves_spec_by_id( db: Session = Depends(get_session), ): tools = Tools.get_tool_by_id(id, db=db) - if tools: - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module - - if hasattr(tools_module, "UserValves"): - UserValves = tools_module.UserValves - schema = UserValves.schema() - # Resolve dynamic options for select dropdowns - schema = resolve_valves_schema_options(UserValves, schema, user) - return schema - return None - else: + if not tools: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, + status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) -@router.post("/id/{id}/valves/user/update", response_model=Optional[dict]) + if id in request.app.state.TOOLS: + tools_module = request.app.state.TOOLS[id] + else: + tools_module, _ = load_tool_module_by_id(id) + request.app.state.TOOLS[id] = tools_module + + if hasattr(tools_module, 'UserValves'): + UserValves = tools_module.UserValves + schema = UserValves.schema() + # Resolve dynamic options for select dropdowns + schema = resolve_valves_schema_options(UserValves, schema, user) + return schema + return None + + +@router.post('/id/{id}/valves/user/update', response_model=Optional[dict]) async def update_tools_user_valves_by_id( request: Request, id: str, @@ -824,35 +848,48 @@ async def update_tools_user_valves_by_id( db: Session = Depends(get_session), ): tools = Tools.get_tool_by_id(id, db=db) + if not tools: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) - if tools: - if id in request.app.state.TOOLS: - tools_module = request.app.state.TOOLS[id] - else: - tools_module, _ = load_tool_module_by_id(id) - request.app.state.TOOLS[id] = tools_module - - if hasattr(tools_module, "UserValves"): - UserValves = tools_module.UserValves - - try: - form_data = {k: v for k, v in form_data.items() if v is not None} - user_valves = UserValves(**form_data) - user_valves_dict = user_valves.model_dump(exclude_unset=True) - Tools.update_user_valves_by_id_and_user_id( - id, user.id, user_valves_dict, db=db - ) - return user_valves_dict - except Exception as e: - log.exception(f"Failed to update user valves by id {id}: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.DEFAULT(str(e)), - ) - else: + if ( + tools.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type='tool', + resource_id=tools.id, + permission='read', + db=db, + ) + and user.role != 'admin' + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if id in request.app.state.TOOLS: + tools_module = request.app.state.TOOLS[id] + else: + tools_module, _ = load_tool_module_by_id(id) + request.app.state.TOOLS[id] = tools_module + + if hasattr(tools_module, 'UserValves'): + UserValves = tools_module.UserValves + + try: + form_data = {k: v for k, v in form_data.items() if v is not None} + user_valves = UserValves(**form_data) + user_valves_dict = user_valves.model_dump(exclude_unset=True) + Tools.update_user_valves_by_id_and_user_id(id, user.id, user_valves_dict, db=db) + return user_valves_dict + except Exception as e: + log.exception(f'Failed to update user valves by id {id}: {e}') raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=ERROR_MESSAGES.NOT_FOUND, + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), ) else: raise HTTPException( diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 961732c28e..43e5928337 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -56,13 +56,15 @@ ############################ # GetUsers +# A house is only as strong as its care for the least of +# its members. Let none here be counted without being served. ############################ PAGE_ITEM_COUNT = 30 -@router.get("/", response_model=UserGroupIdsListResponse) +@router.get('/', response_model=UserGroupIdsListResponse) async def get_users( query: Optional[str] = None, order_by: Optional[str] = None, @@ -78,65 +80,61 @@ async def get_users( filter = {} if query: - filter["query"] = query + filter['query'] = query if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction - filter["direction"] = direction + filter['direction'] = direction result = Users.get_users(filter=filter, skip=skip, limit=limit, db=db) - users = result["users"] - total = result["total"] + users = result['users'] + total = result['total'] # Fetch groups for all users in a single query to avoid N+1 user_ids = [user.id for user in users] user_groups = Groups.get_groups_by_member_ids(user_ids, db=db) credit_map = { - credit.user_id: {"credit": "%.4f" % credit.credit} - for credit in Credits.list_credits_by_user_id( - user_ids=(user.id for user in users) - ) + credit.user_id: {'credit': '%.4f' % credit.credit} + for credit in Credits.list_credits_by_user_id(user_ids=(user.id for user in users)) } for user in users: - setattr(user, "credit", credit_map.get(user.id, {}).get("credit", 0)) + setattr(user, 'credit', credit_map.get(user.id, {}).get('credit', 0)) return { - "users": [ + 'users': [ UserGroupIdsModel( **{ **user.model_dump(), - "group_ids": [group.id for group in user_groups.get(user.id, [])], + 'group_ids': [group.id for group in user_groups.get(user.id, [])], } ) for user in users ], - "total": total, + 'total': total, } -@router.get("/all", response_model=UserInfoListResponse) +@router.get('/all', response_model=UserInfoListResponse) async def get_all_users( user=Depends(get_admin_user), db: Session = Depends(get_session), ): user_data = Users.get_users(db=db) - users = user_data["users"] + users = user_data['users'] credit_map = { - credit.user_id: {"credit": "%.4f" % credit.credit} - for credit in Credits.list_credits_by_user_id( - user_ids=(user.id for user in users) - ) + credit.user_id: {'credit': '%.4f' % credit.credit} + for credit in Credits.list_credits_by_user_id(user_ids=(user.id for user in users)) } for user in users: - setattr(user, "credit", credit_map.get(user.id, {}).get("credit", 0)) + setattr(user, 'credit', credit_map.get(user.id, {}).get('credit', 0)) return user_data -@router.get("/search", response_model=UserInfoListResponse) +@router.get('/search', response_model=UserInfoListResponse) async def search_users( query: Optional[str] = None, order_by: Optional[str] = None, @@ -152,11 +150,11 @@ async def search_users( filter = {} if query: - filter["query"] = query + filter['query'] = query if order_by: - filter["order_by"] = order_by + filter['order_by'] = order_by if direction: - filter["direction"] = direction + filter['direction'] = direction return Users.get_users(filter=filter, skip=skip, limit=limit, db=db) @@ -166,10 +164,8 @@ async def search_users( ############################ -@router.get("/groups") -async def get_user_groups( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/groups') +async def get_user_groups(user=Depends(get_verified_user), db: Session = Depends(get_session)): return Groups.get_groups_by_member_id(user.id, db=db) @@ -178,15 +174,13 @@ async def get_user_groups( ############################ -@router.get("/permissions") +@router.get('/permissions') async def get_user_permissisions( request: Request, user=Depends(get_verified_user), db: Session = Depends(get_session), ): - user_permissions = get_permissions( - user.id, request.app.state.config.USER_PERMISSIONS, db=db - ) + user_permissions = get_permissions(user.id, request.app.state.config.USER_PERMISSIONS, db=db) return user_permissions @@ -276,34 +270,20 @@ class UserPermissions(BaseModel): settings: SettingsPermissions -@router.get("/default/permissions", response_model=UserPermissions) +@router.get('/default/permissions', response_model=UserPermissions) async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)): return { - "workspace": WorkspacePermissions( - **request.app.state.config.USER_PERMISSIONS.get("workspace", {}) - ), - "sharing": SharingPermissions( - **request.app.state.config.USER_PERMISSIONS.get("sharing", {}) - ), - "access_grants": AccessGrantsPermissions( - **request.app.state.config.USER_PERMISSIONS.get("access_grants", {}) - ), - "chat": ChatPermissions( - **request.app.state.config.USER_PERMISSIONS.get("chat", {}) - ), - "features": FeaturesPermissions( - **request.app.state.config.USER_PERMISSIONS.get("features", {}) - ), - "settings": SettingsPermissions( - **request.app.state.config.USER_PERMISSIONS.get("settings", {}) - ), + 'workspace': WorkspacePermissions(**request.app.state.config.USER_PERMISSIONS.get('workspace', {})), + 'sharing': SharingPermissions(**request.app.state.config.USER_PERMISSIONS.get('sharing', {})), + 'access_grants': AccessGrantsPermissions(**request.app.state.config.USER_PERMISSIONS.get('access_grants', {})), + 'chat': ChatPermissions(**request.app.state.config.USER_PERMISSIONS.get('chat', {})), + 'features': FeaturesPermissions(**request.app.state.config.USER_PERMISSIONS.get('features', {})), + 'settings': SettingsPermissions(**request.app.state.config.USER_PERMISSIONS.get('settings', {})), } -@router.post("/default/permissions") -async def update_default_user_permissions( - request: Request, form_data: UserPermissions, user=Depends(get_admin_user) -): +@router.post('/default/permissions') +async def update_default_user_permissions(request: Request, form_data: UserPermissions, user=Depends(get_admin_user)): request.app.state.config.USER_PERMISSIONS = form_data.model_dump() return request.app.state.config.USER_PERMISSIONS @@ -313,10 +293,8 @@ async def update_default_user_permissions( ############################ -@router.get("/user/settings", response_model=Optional[UserSettings]) -async def get_user_settings_by_session_user( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/user/settings', response_model=Optional[UserSettings]) +async def get_user_settings_by_session_user(user=Depends(get_verified_user), db: Session = Depends(get_session)): user = Users.get_user_by_id(user.id, db=db) if user: return user.settings @@ -332,7 +310,7 @@ async def get_user_settings_by_session_user( ############################ -@router.post("/user/settings/update", response_model=UserSettings) +@router.post('/user/settings/update', response_model=UserSettings) async def update_user_settings_by_session_user( request: Request, form_data: UserSettings, @@ -340,19 +318,19 @@ async def update_user_settings_by_session_user( db: Session = Depends(get_session), ): updated_user_settings = form_data.model_dump() - ui_settings = updated_user_settings.get("ui") + ui_settings = updated_user_settings.get('ui') if ( - user.role != "admin" + user.role != 'admin' and ui_settings is not None - and "toolServers" in ui_settings.keys() + and 'toolServers' in ui_settings.keys() and not has_permission( user.id, - "features.direct_tool_servers", + 'features.direct_tool_servers', request.app.state.config.USER_PERMISSIONS, ) ): # 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) + updated_user_settings['ui'].pop('toolServers', None) user = Users.update_user_settings_by_id(user.id, updated_user_settings, db=db) if user: @@ -369,7 +347,7 @@ async def update_user_settings_by_session_user( ############################ -@router.get("/user/status") +@router.get('/user/status') async def get_user_status_by_session_user( request: Request, user=Depends(get_verified_user), @@ -395,7 +373,7 @@ async def get_user_status_by_session_user( ############################ -@router.post("/user/status/update") +@router.post('/user/status/update') async def update_user_status_by_session_user( request: Request, form_data: UserStatus, @@ -423,10 +401,8 @@ async def update_user_status_by_session_user( ############################ -@router.get("/user/info", response_model=Optional[dict]) -async def get_user_info_by_session_user( - user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/user/info', response_model=Optional[dict]) +async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Session = Depends(get_session)): user = Users.get_user_by_id(user.id, db=db) if user: return user.info @@ -442,7 +418,7 @@ async def get_user_info_by_session_user( ############################ -@router.post("/user/info/update", response_model=Optional[dict]) +@router.post('/user/info/update', response_model=Optional[dict]) async def update_user_info_by_session_user( form_data: dict, user=Depends(get_verified_user), db: Session = Depends(get_session) ): @@ -451,9 +427,7 @@ async def update_user_info_by_session_user( if user.info is None: user.info = {} - user = Users.update_user_by_id( - user.id, {"info": {**user.info, **form_data}}, db=db - ) + user = Users.update_user_by_id(user.id, {'info': {**user.info, **form_data}}, db=db) if user: return user.info else: @@ -479,17 +453,15 @@ class UserActiveResponse(UserStatus): groups: Optional[list] = [] is_active: bool - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') -@router.get("/{user_id}", response_model=UserActiveResponse) -async def get_user_by_id( - user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/{user_id}', response_model=UserActiveResponse) +async def get_user_by_id(user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): # Check if user_id is a shared chat # If it is, get the user_id from the chat - if user_id.startswith("shared-"): - chat_id = user_id.replace("shared-", "") + if user_id.startswith('shared-'): + chat_id = user_id.replace('shared-', '') chat = Chats.get_chat_by_id(chat_id) if chat: user_id = chat.user_id @@ -505,8 +477,8 @@ async def get_user_by_id( return UserActiveResponse( **{ **user.model_dump(), - "groups": [{"id": group.id, "name": group.name} for group in groups], - "is_active": Users.is_user_active(user_id, db=db), + 'groups': [{'id': group.id, 'name': group.name} for group in groups], + 'is_active': Users.is_user_active(user_id, db=db), } ) else: @@ -516,18 +488,16 @@ async def get_user_by_id( ) -@router.get("/{user_id}/info", response_model=UserInfoResponse) -async def get_user_info_by_id( - user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) -): +@router.get('/{user_id}/info', response_model=UserInfoResponse) +async def get_user_info_by_id(user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)): user = Users.get_user_by_id(user_id, db=db) if user: groups = Groups.get_groups_by_member_id(user_id, db=db) return UserInfoResponse( **{ **user.model_dump(), - "groups": [{"id": group.id, "name": group.name} for group in groups], - "is_active": Users.is_user_active(user_id, db=db), + 'groups': [{'id': group.id, 'name': group.name} for group in groups], + 'is_active': Users.is_user_active(user_id, db=db), } ) else: @@ -537,10 +507,8 @@ async def get_user_info_by_id( ) -@router.get("/{user_id}/oauth/sessions") -async def get_user_oauth_sessions_by_id( - user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/{user_id}/oauth/sessions') +async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): sessions = OAuthSessions.get_sessions_by_user_id(user_id, db=db) if sessions and len(sessions) > 0: return sessions @@ -556,32 +524,32 @@ async def get_user_oauth_sessions_by_id( ############################ -@router.get("/{user_id}/profile/image") +@router.get('/{user_id}/profile/image') def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)): user = Users.get_user_by_id(user_id) if user: if user.profile_image_url: # check if it's url or base64 - if user.profile_image_url.startswith("http"): + if user.profile_image_url.startswith('http'): return Response( status_code=status.HTTP_302_FOUND, - headers={"Location": user.profile_image_url}, + headers={'Location': user.profile_image_url}, ) - elif user.profile_image_url.startswith("data:image"): + elif user.profile_image_url.startswith('data:image'): try: - header, base64_data = user.profile_image_url.split(",", 1) + header, base64_data = user.profile_image_url.split(',', 1) image_data = base64.b64decode(base64_data) image_buffer = io.BytesIO(image_data) - media_type = header.split(";")[0].lstrip("data:") + media_type = header.split(';')[0].lstrip('data:') return StreamingResponse( image_buffer, media_type=media_type, - headers={"Content-Disposition": "inline"}, + headers={'Content-Disposition': 'inline'}, ) except Exception as e: pass - return FileResponse(f"{STATIC_DIR}/user.png") + return FileResponse(f'{STATIC_DIR}/user.png') else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -594,12 +562,12 @@ def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)): ############################ -@router.get("/{user_id}/active", response_model=dict) +@router.get('/{user_id}/active', response_model=dict) async def get_user_active_status_by_id( user_id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) ): return { - "active": Users.is_user_active(user_id, db=db), + 'active': Users.is_user_active(user_id, db=db), } @@ -608,7 +576,7 @@ async def get_user_active_status_by_id( ############################ -@router.post("/{user_id}/update", response_model=Optional[UserModel]) +@router.post('/{user_id}/update', response_model=Optional[UserModel]) async def update_user_by_id( request: Request, user_id: str, @@ -628,7 +596,7 @@ async def update_user_by_id( detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) - if form_data.role != "admin": + if form_data.role != 'admin': # If the primary admin is trying to change their own role, prevent it raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -636,10 +604,10 @@ async def update_user_by_id( ) except Exception as e: - log.error(f"Error checking primary admin status: {e}") + log.error(f'Error checking primary admin status: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Could not verify primary admin status.", + detail='Could not verify primary admin status.', ) user = Users.get_user_by_id(user_id, db=db) @@ -666,10 +634,10 @@ async def update_user_by_id( updated_user = Users.update_user_by_id( user_id, { - "role": form_data.role, - "name": form_data.name, - "email": form_data.email.lower(), - "profile_image_url": form_data.profile_image_url, + 'role': form_data.role, + 'name': form_data.name, + 'email': form_data.email.lower(), + 'profile_image_url': form_data.profile_image_url, }, db=db, ) @@ -681,12 +649,12 @@ async def update_user_by_id( credit=Decimal(form_data.credit), detail=SetCreditFormDetail( api_path=str(request.url), - api_params={"credit": form_data.credit}, - desc=f"updated by {session_user.name}", + api_params={'credit': form_data.credit}, + desc=f'updated by {session_user.name}', ), ) ) - setattr(updated_user, "credit", "%.4f" % credit.credit) + setattr(updated_user, 'credit', '%.4f' % credit.credit) if updated_user: return updated_user @@ -707,7 +675,7 @@ async def update_user_by_id( ############################ -@router.put("/{user_id}/credit", response_model=Optional[UserModel]) +@router.put('/{user_id}/credit', response_model=Optional[UserModel]) async def update_credit_by_user_id( request: Request, user_id: str, @@ -724,23 +692,23 @@ async def update_credit_by_user_id( if form_data.amount is None and form_data.credit is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="amount or credit must be specified", + detail='amount or credit must be specified', ) params = { - "user_id": user_id, - "detail": SetCreditFormDetail( + 'user_id': user_id, + 'detail': SetCreditFormDetail( api_path=str(request.url), api_params=form_data.model_dump(), - desc=f"updated by {session_user.name}", + desc=f'updated by {session_user.name}', ), } if form_data.credit is not None: - params["credit"] = Decimal(form_data.credit) + params['credit'] = Decimal(form_data.credit) Credits.set_credit_by_user_id(form_data=SetCreditForm(**params)) elif form_data.amount is not None: - params["amount"] = Decimal(form_data.amount) + params['amount'] = Decimal(form_data.amount) Credits.add_credit_by_user_id(form_data=AddCreditForm(**params)) return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -751,10 +719,8 @@ async def update_credit_by_user_id( ############################ -@router.delete("/{user_id}", response_model=bool) -async def delete_user_by_id( - user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.delete('/{user_id}', response_model=bool) +async def delete_user_by_id(user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): # Prevent deletion of the primary admin user try: first_user = Users.get_first_user(db=db) @@ -764,10 +730,10 @@ async def delete_user_by_id( detail=ERROR_MESSAGES.ACTION_PROHIBITED, ) except Exception as e: - log.error(f"Error checking primary admin status: {e}") + log.error(f'Error checking primary admin status: {e}') raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Could not verify primary admin status.", + detail='Could not verify primary admin status.', ) if user.id != user_id: @@ -793,8 +759,6 @@ async def delete_user_by_id( ############################ -@router.get("/{user_id}/groups") -async def get_user_groups_by_id( - user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session) -): +@router.get('/{user_id}/groups') +async def get_user_groups_by_id(user_id: str, user=Depends(get_admin_user), db: Session = Depends(get_session)): return Groups.get_groups_by_member_id(user_id, db=db) diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index 49f3a5ca55..7ea4150021 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -20,7 +20,7 @@ router = APIRouter() -@router.get("/gravatar") +@router.get('/gravatar') async def get_gravatar(email: str, user=Depends(get_verified_user)): return get_gravatar_url(email) @@ -29,33 +29,31 @@ class CodeForm(BaseModel): code: str -@router.post("/code/format") +@router.post('/code/format') async def format_code(form_data: CodeForm, user=Depends(get_admin_user)): try: formatted_code = black.format_str(form_data.code, mode=black.Mode()) - return {"code": formatted_code} + return {'code': formatted_code} except black.NothingChanged: - return {"code": form_data.code} + return {'code': form_data.code} except Exception as e: raise HTTPException(status_code=400, detail=str(e)) -@router.post("/code/execute") -async def execute_code( - request: Request, form_data: CodeForm, user=Depends(get_verified_user) -): - if request.app.state.config.CODE_EXECUTION_ENGINE == "jupyter": +@router.post('/code/execute') +async def execute_code(request: Request, form_data: CodeForm, user=Depends(get_verified_user)): + if request.app.state.config.CODE_EXECUTION_ENGINE == 'jupyter': output = await execute_code_jupyter( request.app.state.config.CODE_EXECUTION_JUPYTER_URL, form_data.code, ( request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_TOKEN - if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == "token" + if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == 'token' else None ), ( request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH_PASSWORD - if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == "password" + if request.app.state.config.CODE_EXECUTION_JUPYTER_AUTH == 'password' else None ), request.app.state.config.CODE_EXECUTION_JUPYTER_TIMEOUT, @@ -65,7 +63,7 @@ async def execute_code( else: raise HTTPException( status_code=400, - detail="Code execution engine not supported", + detail='Code execution engine not supported', ) @@ -73,11 +71,9 @@ class MarkdownForm(BaseModel): md: str -@router.post("/markdown") -async def get_html_from_markdown( - form_data: MarkdownForm, user=Depends(get_verified_user) -): - return {"html": markdown.markdown(form_data.md)} +@router.post('/markdown') +async def get_html_from_markdown(form_data: MarkdownForm, user=Depends(get_verified_user)): + return {'html': markdown.markdown(form_data.md)} class ChatForm(BaseModel): @@ -85,24 +81,22 @@ class ChatForm(BaseModel): messages: list[dict] -@router.post("/pdf") -async def download_chat_as_pdf( - form_data: ChatTitleMessagesForm, user=Depends(get_verified_user) -): +@router.post('/pdf') +async def download_chat_as_pdf(form_data: ChatTitleMessagesForm, user=Depends(get_verified_user)): try: pdf_bytes = PDFGenerator(form_data).generate_chat_pdf() return Response( content=pdf_bytes, - media_type="application/pdf", - headers={"Content-Disposition": "attachment;filename=chat.pdf"}, + media_type='application/pdf', + headers={'Content-Disposition': 'attachment;filename=chat.pdf'}, ) except Exception as e: - log.exception(f"Error generating PDF: {e}") + log.exception(f'Error generating PDF: {e}') raise HTTPException(status_code=400, detail=str(e)) -@router.get("/db/download") +@router.get('/db/download') async def download_db(user=Depends(get_admin_user)): if not ENABLE_ADMIN_EXPORT: raise HTTPException( @@ -111,13 +105,13 @@ async def download_db(user=Depends(get_admin_user)): ) from open_webui.internal.db import engine - if engine.name != "sqlite": + if engine.name != 'sqlite': raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DB_NOT_SQLITE, ) return FileResponse( engine.url.database, - media_type="application/octet-stream", - filename="webui.db", + media_type='application/octet-stream', + filename='webui.db', ) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 3070959332..33c9ffea05 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -55,27 +55,25 @@ log = logging.getLogger(__name__) +# Let no connection opened in good faith be dropped without +# cause, and let every message find the room it was meant for. REDIS = None # Configure CORS for Socket.IO -SOCKETIO_CORS_ORIGINS = "*" if CORS_ALLOW_ORIGIN == ["*"] else CORS_ALLOW_ORIGIN +SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN -if WEBSOCKET_MANAGER == "redis": +if WEBSOCKET_MANAGER == 'redis': if WEBSOCKET_SENTINEL_HOSTS: mgr = socketio.AsyncRedisManager( - get_sentinel_url_from_env( - WEBSOCKET_REDIS_URL, WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT - ), + get_sentinel_url_from_env(WEBSOCKET_REDIS_URL, WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT), redis_options=WEBSOCKET_REDIS_OPTIONS, ) else: - mgr = socketio.AsyncRedisManager( - WEBSOCKET_REDIS_URL, redis_options=WEBSOCKET_REDIS_OPTIONS - ) + mgr = socketio.AsyncRedisManager(WEBSOCKET_REDIS_URL, redis_options=WEBSOCKET_REDIS_OPTIONS) sio = socketio.AsyncServer( cors_allowed_origins=SOCKETIO_CORS_ORIGINS, - async_mode="asgi", - transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]), + async_mode='asgi', + transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']), allow_upgrades=ENABLE_WEBSOCKET_SUPPORT, always_connect=True, client_manager=mgr, @@ -87,8 +85,8 @@ else: sio = socketio.AsyncServer( cors_allowed_origins=SOCKETIO_CORS_ORIGINS, - async_mode="asgi", - transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]), + async_mode='asgi', + transports=(['websocket'] if ENABLE_WEBSOCKET_SUPPORT else ['polling']), allow_upgrades=ENABLE_WEBSOCKET_SUPPORT, always_connect=True, logger=WEBSOCKET_SERVER_LOGGING, @@ -104,36 +102,32 @@ # Dictionary to maintain the user pool -if WEBSOCKET_MANAGER == "redis": - log.debug("Using Redis to manage websockets.") +if WEBSOCKET_MANAGER == 'redis': + log.debug('Using Redis to manage websockets.') REDIS = get_redis_connection( redis_url=WEBSOCKET_REDIS_URL, - redis_sentinels=get_sentinels_from_env( - WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT - ), + redis_sentinels=get_sentinels_from_env(WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT), redis_cluster=WEBSOCKET_REDIS_CLUSTER, async_mode=True, ) - redis_sentinels = get_sentinels_from_env( - WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT - ) + redis_sentinels = get_sentinels_from_env(WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT) MODELS = RedisDict( - f"{REDIS_KEY_PREFIX}:models", + f'{REDIS_KEY_PREFIX}:models', redis_url=WEBSOCKET_REDIS_URL, redis_sentinels=redis_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) SESSION_POOL = RedisDict( - f"{REDIS_KEY_PREFIX}:session_pool", + f'{REDIS_KEY_PREFIX}:session_pool', redis_url=WEBSOCKET_REDIS_URL, redis_sentinels=redis_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, ) USAGE_POOL = RedisDict( - f"{REDIS_KEY_PREFIX}:usage_pool", + f'{REDIS_KEY_PREFIX}:usage_pool', redis_url=WEBSOCKET_REDIS_URL, redis_sentinels=redis_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, @@ -141,7 +135,7 @@ clean_up_lock = RedisLock( redis_url=WEBSOCKET_REDIS_URL, - lock_name=f"{REDIS_KEY_PREFIX}:usage_cleanup_lock", + lock_name=f'{REDIS_KEY_PREFIX}:usage_cleanup_lock', timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT, redis_sentinels=redis_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, @@ -152,7 +146,7 @@ session_cleanup_lock = RedisLock( redis_url=WEBSOCKET_REDIS_URL, - lock_name=f"{REDIS_KEY_PREFIX}:session_cleanup_lock", + lock_name=f'{REDIS_KEY_PREFIX}:session_cleanup_lock', timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT, redis_sentinels=redis_sentinels, redis_cluster=WEBSOCKET_REDIS_CLUSTER, @@ -172,29 +166,27 @@ YDOC_MANAGER = YdocManager( redis=REDIS, - redis_key_prefix=f"{REDIS_KEY_PREFIX}:ydoc:documents", + redis_key_prefix=f'{REDIS_KEY_PREFIX}:ydoc:documents', ) 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.") + log.debug('Session cleanup lock held by another node. Skipping.') return try: while True: if not session_renew_func(): - log.error("Unable to renew session cleanup lock. Exiting.") + log.error('Unable to renew session cleanup lock. Exiting.') return 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')})" - ) + 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: @@ -203,46 +195,38 @@ async def periodic_session_pool_cleanup(): async def periodic_usage_pool_cleanup(): max_retries = 2 - retry_delay = random.uniform( - WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT - ) + retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT) for attempt in range(max_retries + 1): if aquire_func(): break else: if attempt < max_retries: - log.debug( - f"Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s..." - ) + log.debug(f'Cleanup lock already exists. Retry {attempt + 1} after {retry_delay}s...') await asyncio.sleep(retry_delay) else: - log.warning( - "Failed to acquire cleanup lock after retries. Skipping cleanup." - ) + log.warning('Failed to acquire cleanup lock after retries. Skipping cleanup.') return - log.debug("Running periodic_cleanup") + log.debug('Running periodic_cleanup') try: while True: if not renew_func(): - log.error(f"Unable to renew cleanup lock. Exiting usage pool cleanup.") - raise Exception("Unable to renew usage pool cleanup lock.") + log.error(f'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 + sid for sid, details in connections.items() if now - details['updated_at'] > TIMEOUT_DURATION ] for sid in expired_sids: del connections[sid] if not connections: - log.debug(f"Cleaning up model {model_id} from usage pool") + log.debug(f'Cleaning up model {model_id} from usage pool') del USAGE_POOL[model_id] else: USAGE_POOL[model_id] = connections @@ -255,7 +239,7 @@ async def periodic_usage_pool_cleanup(): app = socketio.ASGIApp( sio, - socketio_path="/ws/socket.io", + socketio_path='/ws/socket.io', ) @@ -268,14 +252,14 @@ def get_models_in_use(): def get_user_id_from_session_pool(sid): user = SESSION_POOL.get(sid) if user: - return user["id"] + return user['id'] return None def get_session_ids_from_room(room): """Get all session IDs from a specific room.""" active_session_ids = sio.manager.get_participants( - namespace="/", + namespace='/', room=room, ) return [session_id[0] for session_id in active_session_ids] @@ -287,7 +271,7 @@ def get_user_ids_from_room(room): active_user_ids = list( set( [ - SESSION_POOL.get(session_id)["id"] + SESSION_POOL.get(session_id)['id'] for session_id in active_session_ids if SESSION_POOL.get(session_id) is not None ] @@ -307,9 +291,9 @@ async def emit_to_users(event: str, data: dict, user_ids: list[str]): """ try: for user_id in user_ids: - await sio.emit(event, data, room=f"user:{user_id}") + await sio.emit(event, data, room=f'user:{user_id}') except Exception as e: - log.debug(f"Failed to emit event {event} to users {user_ids}: {e}") + log.debug(f'Failed to emit event {event} to users {user_ids}: {e}') async def enter_room_for_users(room: str, user_ids: list[str]): @@ -321,163 +305,162 @@ async def enter_room_for_users(room: str, user_ids: list[str]): """ try: for user_id in user_ids: - session_ids = get_session_ids_from_room(f"user:{user_id}") + session_ids = get_session_ids_from_room(f'user:{user_id}') for sid in session_ids: await sio.enter_room(sid, room) except Exception as e: - log.debug(f"Failed to make users {user_ids} join room {room}: {e}") + log.debug(f'Failed to make users {user_ids} join room {room}: {e}') -@sio.on("usage") +@sio.on('usage') async def usage(sid, data): if sid in SESSION_POOL: - model_id = data["model"] + model_id = data['model'] # Record the timestamp for the last update current_time = int(time.time()) # Store the new usage data and task USAGE_POOL[model_id] = { **(USAGE_POOL[model_id] if model_id in USAGE_POOL else {}), - sid: {"updated_at": current_time}, + sid: {'updated_at': current_time}, } @sio.event async def connect(sid, environ, auth): user = None - if auth and "token" in auth: - data = decode_token(auth["token"]) + if auth and 'token' in auth: + data = decode_token(auth['token']) - if data is not None and "id" in data: - user = Users.get_user_by_id(data["id"]) + if data is not None and 'id' in data: + user = Users.get_user_by_id(data['id']) if user: SESSION_POOL[sid] = { **user.model_dump( exclude=[ - "profile_image_url", - "profile_banner_image_url", - "date_of_birth", - "bio", - "gender", + 'profile_image_url', + 'profile_banner_image_url', + 'date_of_birth', + 'bio', + 'gender', ] ), - "last_seen_at": int(time.time()), + 'last_seen_at': int(time.time()), } - await sio.enter_room(sid, f"user:{user.id}") + await sio.enter_room(sid, f'user:{user.id}') -@sio.on("user-join") +@sio.on('user-join') async def user_join(sid, data): - - auth = data["auth"] if "auth" in data else None - if not auth or "token" not in auth: + auth = data['auth'] if 'auth' in data else None + if not auth or 'token' not in auth: return - data = decode_token(auth["token"]) - if data is None or "id" not in data: + data = decode_token(auth['token']) + if data is None or 'id' not in data: return - user = Users.get_user_by_id(data["id"]) + user = Users.get_user_by_id(data['id']) if not user: return SESSION_POOL[sid] = { **user.model_dump( exclude=[ - "profile_image_url", - "profile_banner_image_url", - "date_of_birth", - "bio", - "gender", + 'profile_image_url', + 'profile_banner_image_url', + 'date_of_birth', + 'bio', + 'gender', ] ), - "last_seen_at": int(time.time()), + 'last_seen_at': int(time.time()), } - await sio.enter_room(sid, f"user:{user.id}") + await sio.enter_room(sid, f'user:{user.id}') # Join all the channels only if user has channels permission - if user.role == "admin" or has_permission(user.id, "features.channels"): + if user.role == 'admin' or has_permission(user.id, 'features.channels'): channels = Channels.get_channels_by_user_id(user.id) - log.debug(f"{channels=}") + log.debug(f'{channels=}') for channel in channels: - await sio.enter_room(sid, f"channel:{channel.id}") + await sio.enter_room(sid, f'channel:{channel.id}') - return {"id": user.id, "name": user.name} + return {'id': user.id, 'name': user.name} -@sio.on("heartbeat") +@sio.on('heartbeat') async def heartbeat(sid, data): user = SESSION_POOL.get(sid) if user: - SESSION_POOL[sid] = {**user, "last_seen_at": int(time.time())} - Users.update_last_active_by_id(user["id"]) + SESSION_POOL[sid] = {**user, 'last_seen_at': int(time.time())} + await asyncio.to_thread(Users.update_last_active_by_id, user['id']) -@sio.on("join-channels") +@sio.on('join-channels') async def join_channel(sid, data): - auth = data["auth"] if "auth" in data else None - if not auth or "token" not in auth: + auth = data['auth'] if 'auth' in data else None + if not auth or 'token' not in auth: return - data = decode_token(auth["token"]) - if data is None or "id" not in data: + data = decode_token(auth['token']) + if data is None or 'id' not in data: return - user = Users.get_user_by_id(data["id"]) + user = Users.get_user_by_id(data['id']) if not user: return # Join all the channels only if user has channels permission - if user.role == "admin" or has_permission(user.id, "features.channels"): + if user.role == 'admin' or has_permission(user.id, 'features.channels'): channels = Channels.get_channels_by_user_id(user.id) - log.debug(f"{channels=}") + log.debug(f'{channels=}') for channel in channels: - await sio.enter_room(sid, f"channel:{channel.id}") + await sio.enter_room(sid, f'channel:{channel.id}') -@sio.on("join-note") +@sio.on('join-note') async def join_note(sid, data): - auth = data["auth"] if "auth" in data else None - if not auth or "token" not in auth: + auth = data['auth'] if 'auth' in data else None + if not auth or 'token' not in auth: return - token_data = decode_token(auth["token"]) - if token_data is None or "id" not in token_data: + token_data = decode_token(auth['token']) + if token_data is None or 'id' not in token_data: return - user = Users.get_user_by_id(token_data["id"]) + user = Users.get_user_by_id(token_data['id']) if not user: return - note = Notes.get_note_by_id(data["note_id"]) + note = Notes.get_note_by_id(data['note_id']) if not note: - log.error(f"Note {data['note_id']} not found for user {user.id}") + log.error(f'Note {data["note_id"]} not found for user {user.id}') return if ( - user.role != "admin" + user.role != 'admin' and user.id != note.user_id and not AccessGrants.has_access( user_id=user.id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="read", + permission='read', ) ): - log.error(f"User {user.id} does not have access to note {data['note_id']}") + log.error(f'User {user.id} does not have access to note {data["note_id"]}') return - log.debug(f"Joining note {note.id} for user {user.id}") - await sio.enter_room(sid, f"note:{note.id}") + log.debug(f'Joining note {note.id} for user {user.id}') + await sio.enter_room(sid, f'note:{note.id}') -@sio.on("events:channel") +@sio.on('events:channel') async def channel_events(sid, data): - room = f"channel:{data['channel_id']}" + room = f'channel:{data["channel_id"]}' participants = sio.manager.get_participants( - namespace="/", + namespace='/', room=room, ) @@ -485,30 +468,43 @@ async def channel_events(sid, data): if sid not in sids: return - event_data = data["data"] - event_type = event_data["type"] + event_data = data['data'] + event_type = event_data['type'] user = SESSION_POOL.get(sid) if not user: return - if event_type == "typing": + if event_type == 'typing': await sio.emit( - "events:channel", + 'events:channel', { - "channel_id": data["channel_id"], - "message_id": data.get("message_id", None), - "data": event_data, - "user": UserNameResponse(**user).model_dump(), + 'channel_id': data['channel_id'], + 'message_id': data.get('message_id', None), + 'data': event_data, + 'user': UserNameResponse(**user).model_dump(), }, room=room, ) - elif event_type == "last_read_at": - Channels.update_member_last_read_at(data["channel_id"], user["id"]) + elif event_type == 'last_read_at': + Channels.update_member_last_read_at(data['channel_id'], user['id']) + +def normalize_document_id(document_id: str) -> str: + """Canonicalize document IDs to prevent auth bypass via prefix variants. + + YdocManager normalizes storage keys by replacing ":" with "_", so + "note_abc" and "note:abc" resolve to the same underlying document. + We must rewrite underscore-prefixed IDs back to the colon form so + that authorization checks (which key on "note:") always fire. + """ + if document_id.startswith('note_'): + document_id = 'note:' + document_id[5:] + return document_id -@sio.on("ydoc:document:join") + +@sio.on('ydoc:document:join') async def ydoc_document_join(sid, data): """Handle user joining a document""" user = SESSION_POOL.get(sid) @@ -516,41 +512,39 @@ async def ydoc_document_join(sid, data): return try: - document_id = data["document_id"] + document_id = normalize_document_id(data['document_id']) - if document_id.startswith("note:"): - note_id = document_id.split(":")[1] + if document_id.startswith('note:'): + note_id = document_id.split(':')[1] note = Notes.get_note_by_id(note_id) if not note: - log.error(f"Note {note_id} not found") + log.error(f'Note {note_id} not found') return if ( - user.get("role") != "admin" - and user.get("id") != note.user_id + user.get('role') != 'admin' + and user.get('id') != note.user_id and not AccessGrants.has_access( - user_id=user.get("id"), - resource_type="note", + user_id=user.get('id'), + resource_type='note', resource_id=note.id, - permission="read", + permission='read', ) ): - log.error( - f"User {user.get('id')} does not have access to note {note_id}" - ) + log.error(f'User {user.get("id")} does not have access to note {note_id}') return - user_id = data.get("user_id", sid) - user_name = data.get("user_name", "Anonymous") - user_color = data.get("user_color", "#000000") + user_id = data.get('user_id', sid) + user_name = data.get('user_name', 'Anonymous') + user_color = data.get('user_color', '#000000') - log.info(f"User {user_id} joining document {document_id}") + log.info(f'User {user_id} joining document {document_id}') await YDOC_MANAGER.add_user(document_id=document_id, user_id=sid) # Join Socket.IO room - await sio.enter_room(sid, f"doc_{document_id}") + await sio.enter_room(sid, f'doc_{document_id}') - active_session_ids = get_session_ids_from_room(f"doc_{document_id}") + active_session_ids = get_session_ids_from_room(f'doc_{document_id}') # Get the Yjs document state ydoc = Y.Doc() @@ -561,74 +555,78 @@ async def ydoc_document_join(sid, data): # Encode the entire document state as an update state_update = ydoc.get_update() await sio.emit( - "ydoc:document:state", + 'ydoc:document:state', { - "document_id": document_id, - "state": list(state_update), # Convert bytes to list for JSON - "sessions": active_session_ids, + 'document_id': document_id, + 'state': list(state_update), # Convert bytes to list for JSON + 'sessions': active_session_ids, }, room=sid, ) # Notify other users about the new user await sio.emit( - "ydoc:user:joined", + 'ydoc:user:joined', { - "document_id": document_id, - "user_id": user_id, - "user_name": user_name, - "user_color": user_color, + 'document_id': document_id, + 'user_id': user_id, + 'user_name': user_name, + 'user_color': user_color, }, - room=f"doc_{document_id}", + room=f'doc_{document_id}', skip_sid=sid, ) - log.info(f"User {user_id} successfully joined document {document_id}") + log.info(f'User {user_id} successfully joined document {document_id}') except Exception as e: - log.error(f"Error in yjs_document_join: {e}") - await sio.emit("error", {"message": "Failed to join document"}, room=sid) + log.error(f'Error in yjs_document_join: {e}') + await sio.emit('error', {'message': 'Failed to join document'}, room=sid) async def document_save_handler(document_id, data, user): - if document_id.startswith("note:"): - note_id = document_id.split(":")[1] + document_id = normalize_document_id(document_id) + + if document_id.startswith('note:'): + note_id = document_id.split(':')[1] note = Notes.get_note_by_id(note_id) if not note: - log.error(f"Note {note_id} not found") + log.error(f'Note {note_id} not found') return if ( - user.get("role") != "admin" - and user.get("id") != note.user_id + user.get('role') != 'admin' + and user.get('id') != note.user_id and not AccessGrants.has_access( - user_id=user.get("id"), - resource_type="note", + user_id=user.get('id'), + resource_type='note', resource_id=note.id, - permission="read", + permission='read', ) ): - log.error(f"User {user.get('id')} does not have access to note {note_id}") + log.error(f'User {user.get("id")} does not have access to note {note_id}') return Notes.update_note_by_id(note_id, NoteUpdateForm(data=data)) -@sio.on("ydoc:document:state") +@sio.on('ydoc:document:state') async def yjs_document_state(sid, data): """Send the current state of the Yjs document to the user""" try: - document_id = data["document_id"] - room = f"doc_{document_id}" + document_id = data['document_id'] + + document_id = normalize_document_id(document_id) + room = f'doc_{document_id}' active_session_ids = get_session_ids_from_room(room) if sid not in active_session_ids: - log.warning(f"Session {sid} not in room {room}. Cannot send state.") + log.warning(f'Session {sid} not in room {room}. Cannot send state.') return if not await YDOC_MANAGER.document_exists(document_id): - log.warning(f"Document {document_id} not found") + log.warning(f'Document {document_id} not found') return # Get the Yjs document state @@ -641,32 +639,41 @@ async def yjs_document_state(sid, data): state_update = ydoc.get_update() await sio.emit( - "ydoc:document:state", + 'ydoc:document:state', { - "document_id": document_id, - "state": list(state_update), # Convert bytes to list for JSON - "sessions": active_session_ids, + 'document_id': document_id, + 'state': list(state_update), # Convert bytes to list for JSON + 'sessions': active_session_ids, }, room=sid, ) except Exception as e: - log.error(f"Error in yjs_document_state: {e}") + log.error(f'Error in yjs_document_state: {e}') -@sio.on("ydoc:document:update") +@sio.on('ydoc:document:update') async def yjs_document_update(sid, data): """Handle Yjs document updates""" try: - document_id = data["document_id"] + document_id = data['document_id'] + + document_id = normalize_document_id(document_id) + + # Verify the sender actually joined this document room + room = f'doc_{document_id}' + active_session_ids = get_session_ids_from_room(room) + if sid not in active_session_ids: + log.warning(f'Session {sid} not in room {room}. Rejecting update.') + return try: await stop_item_tasks(REDIS, document_id) - except: + except Exception: pass - user_id = data.get("user_id", sid) + user_id = data.get('user_id', sid) - update = data["update"] # List of bytes from frontend + update = data['update'] # List of bytes from frontend await YDOC_MANAGER.append_to_updates( document_id=document_id, @@ -675,14 +682,14 @@ async def yjs_document_update(sid, data): # Broadcast update to all other users in the document await sio.emit( - "ydoc:document:update", + 'ydoc:document:update', { - "document_id": document_id, - "user_id": user_id, - "update": update, - "socket_id": sid, # Add socket_id to match frontend filtering + 'document_id': document_id, + 'user_id': user_id, + 'update': update, + 'socket_id': sid, # Add socket_id to match frontend filtering }, - room=f"doc_{document_id}", + room=f'doc_{document_id}', skip_sid=sid, ) @@ -692,66 +699,63 @@ async def yjs_document_update(sid, data): async def debounced_save(): await asyncio.sleep(0.5) - await document_save_handler(document_id, data.get("data", {}), user) + await document_save_handler(document_id, data.get('data', {}), user) - if data.get("data"): + if data.get('data'): await create_task(REDIS, debounced_save(), document_id) except Exception as e: - log.error(f"Error in yjs_document_update: {e}") + log.error(f'Error in yjs_document_update: {e}') -@sio.on("ydoc:document:leave") +@sio.on('ydoc:document:leave') async def yjs_document_leave(sid, data): """Handle user leaving a document""" try: - document_id = data["document_id"] - user_id = data.get("user_id", sid) + document_id = data['document_id'] + user_id = data.get('user_id', sid) - log.info(f"User {user_id} leaving document {document_id}") + log.info(f'User {user_id} leaving document {document_id}') # Remove user from the document await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid) # Leave Socket.IO room - await sio.leave_room(sid, f"doc_{document_id}") + await sio.leave_room(sid, f'doc_{document_id}') # Notify other users await sio.emit( - "ydoc:user:left", - {"document_id": document_id, "user_id": user_id}, - room=f"doc_{document_id}", + 'ydoc:user:left', + {'document_id': document_id, 'user_id': user_id}, + room=f'doc_{document_id}', ) - if ( - await YDOC_MANAGER.document_exists(document_id) - and len(await YDOC_MANAGER.get_users(document_id)) == 0 - ): - log.info(f"Cleaning up document {document_id} as no users are left") + if await YDOC_MANAGER.document_exists(document_id) and len(await YDOC_MANAGER.get_users(document_id)) == 0: + log.info(f'Cleaning up document {document_id} as no users are left') await YDOC_MANAGER.clear_document(document_id) except Exception as e: - log.error(f"Error in yjs_document_leave: {e}") + log.error(f'Error in yjs_document_leave: {e}') -@sio.on("ydoc:awareness:update") +@sio.on('ydoc:awareness:update') async def yjs_awareness_update(sid, data): """Handle awareness updates (cursors, selections, etc.)""" try: - document_id = data["document_id"] - user_id = data.get("user_id", sid) - update = data["update"] + document_id = data['document_id'] + user_id = data.get('user_id', sid) + update = data['update'] # Broadcast awareness update to all other users in the document await sio.emit( - "ydoc:awareness:update", - {"document_id": document_id, "user_id": user_id, "update": update}, - room=f"doc_{document_id}", + 'ydoc:awareness:update', + {'document_id': document_id, 'user_id': user_id, 'update': update}, + room=f'doc_{document_id}', skip_sid=sid, ) except Exception as e: - log.error(f"Error in yjs_awareness_update: {e}") + log.error(f'Error in yjs_awareness_update: {e}') @sio.event @@ -778,132 +782,123 @@ async def disconnect(sid): def get_event_emitter(request_info, update_db=True): async def __event_emitter__(event_data): - user_id = request_info["user_id"] - chat_id = request_info["chat_id"] - message_id = request_info["message_id"] + user_id = request_info['user_id'] + chat_id = request_info['chat_id'] + message_id = request_info['message_id'] await sio.emit( - "events", + 'events', { - "chat_id": chat_id, - "message_id": message_id, - "data": event_data, + 'chat_id': chat_id, + 'message_id': message_id, + 'data': event_data, }, - room=f"user:{user_id}", + room=f'user:{user_id}', ) - if ( - update_db - and message_id - and not request_info.get("chat_id", "").startswith("local:") - ): + if update_db and message_id and not request_info.get('chat_id', '').startswith('local:'): + event_type = event_data.get('type') - event_type = event_data.get("type") - - if event_type == "status": + if event_type == 'status': await asyncio.to_thread( Chats.add_message_status_to_chat_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], - event_data.get("data", {}), + request_info['chat_id'], + request_info['message_id'], + event_data.get('data', {}), ) - elif event_type == "message": + elif event_type == 'message': message = await asyncio.to_thread( Chats.get_message_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], ) if message: - content = message.get("content", "") - content += event_data.get("data", {}).get("content", "") + content = message.get('content', '') + content += event_data.get('data', {}).get('content', '') await asyncio.to_thread( Chats.upsert_message_to_chat_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], { - "content": content, + 'content': content, }, ) - elif event_type == "replace": - content = event_data.get("data", {}).get("content", "") + elif event_type == 'replace': + content = event_data.get('data', {}).get('content', '') await asyncio.to_thread( Chats.upsert_message_to_chat_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], { - "content": content, + 'content': content, }, ) - elif event_type == "embeds": + elif event_type == 'embeds': message = await asyncio.to_thread( Chats.get_message_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], ) - embeds = event_data.get("data", {}).get("embeds", []) - embeds.extend(message.get("embeds", [])) + embeds = event_data.get('data', {}).get('embeds', []) + embeds.extend(message.get('embeds', [])) await asyncio.to_thread( Chats.upsert_message_to_chat_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], { - "embeds": embeds, + 'embeds': embeds, }, ) - elif event_type == "files": + elif event_type == 'files': message = await asyncio.to_thread( Chats.get_message_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], ) - files = event_data.get("data", {}).get("files", []) - files.extend(message.get("files", [])) + files = event_data.get('data', {}).get('files', []) + files.extend(message.get('files', [])) await asyncio.to_thread( Chats.upsert_message_to_chat_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], { - "files": files, + 'files': files, }, ) - elif event_type in ("source", "citation"): - data = event_data.get("data", {}) - if data.get("type") is None: + elif event_type in ('source', 'citation'): + data = event_data.get('data', {}) + if data.get('type') is None: message = await asyncio.to_thread( Chats.get_message_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], ) - sources = message.get("sources", []) + sources = message.get('sources', []) sources.append(data) await asyncio.to_thread( Chats.upsert_message_to_chat_by_id_and_message_id, - request_info["chat_id"], - request_info["message_id"], + request_info['chat_id'], + request_info['message_id'], { - "sources": sources, + 'sources': sources, }, ) - if ( - "user_id" in request_info - and "chat_id" in request_info - and "message_id" in request_info - ): + if 'user_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info: return __event_emitter__ else: return None @@ -912,22 +907,18 @@ async def __event_emitter__(event_data): def get_event_call(request_info): async def __event_caller__(event_data): response = await sio.call( - "events", + 'events', { - "chat_id": request_info.get("chat_id", None), - "message_id": request_info.get("message_id", None), - "data": event_data, + 'chat_id': request_info.get('chat_id', None), + 'message_id': request_info.get('message_id', None), + 'data': event_data, }, - to=request_info["session_id"], + to=request_info['session_id'], timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT, ) return response - if ( - "session_id" in request_info - and "chat_id" in request_info - and "message_id" in request_info - ): + if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info: return __event_caller__ else: return None diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index c33af2e71d..16b0cc3855 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -15,7 +15,6 @@ def __init__( redis_sentinels=[], redis_cluster=False, ): - self.lock_name = lock_name self.lock_id = str(uuid.uuid4()) self.timeout_secs = timeout_secs @@ -29,16 +28,12 @@ def __init__( def aquire_lock(self): # nx=True will only set this key if it _hasn't_ already been set - self.lock_obtained = self.redis.set( - self.lock_name, self.lock_id, nx=True, ex=self.timeout_secs - ) + self.lock_obtained = self.redis.set(self.lock_name, self.lock_id, nx=True, ex=self.timeout_secs) 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 self.redis.set(self.lock_name, self.lock_id, xx=True, ex=self.timeout_secs) def release_lock(self): lock_value = self.redis.get(self.lock_name) @@ -87,13 +82,22 @@ def items(self): return [(k, json.loads(v)) for k, v in self.redis.hgetall(self.name).items()] def set(self, mapping: dict): - pipe = self.redis.pipeline() + if not mapping: + self.redis.delete(self.name) + return - pipe.delete(self.name) - if mapping: - pipe.hset(self.name, mapping={k: json.dumps(v) for k, v in mapping.items()}) + # Fetch existing keys before writing so we know which ones to remove. + # HKEYS is cheap โ€” it transfers only short key strings, not large JSON values. + existing_keys = set(self.redis.hkeys(self.name)) + new_keys = set(mapping.keys()) + keys_to_remove = existing_keys - new_keys - pipe.execute() + # HSET first (add/update all new values), then HDEL (remove stale keys). + # We never DELETE the whole hash โ€” this eliminates the race window + # where concurrent readers would see an empty models dict. + self.redis.hset(self.name, mapping={k: json.dumps(v) for k, v in mapping.items()}) + if keys_to_remove: + self.redis.hdel(self.name, *keys_to_remove) def get(self, key, default=None): try: @@ -106,7 +110,7 @@ def clear(self): def update(self, other=None, **kwargs): if other is not None: - for k, v in other.items() if hasattr(other, "items") else other: + for k, v in other.items() if hasattr(other, 'items') else other: self[k] = v for k, v in kwargs.items(): self[k] = v @@ -123,7 +127,7 @@ class YdocManager: def __init__( self, redis=None, - redis_key_prefix: str = f"{REDIS_KEY_PREFIX}:ydoc:documents", + redis_key_prefix: str = f'{REDIS_KEY_PREFIX}:ydoc:documents', ): self._updates = {} self._users = {} @@ -131,9 +135,9 @@ def __init__( self._redis_key_prefix = redis_key_prefix async def append_to_updates(self, document_id: str, update: bytes): - document_id = document_id.replace(":", "_") + document_id = document_id.replace(':', '_') if self._redis: - redis_key = f"{self._redis_key_prefix}:{document_id}:updates" + redis_key = f'{self._redis_key_prefix}:{document_id}:updates' await self._redis.rpush(redis_key, json.dumps(list(update))) list_len = await self._redis.llen(redis_key) if list_len >= self.COMPACTION_THRESHOLD: @@ -147,7 +151,7 @@ async def append_to_updates(self, document_id: str, update: bytes): async def _compact_updates_redis(self, document_id: str): """Rolling compaction: squash oldest half into one snapshot.""" - redis_key = f"{self._redis_key_prefix}:{document_id}:updates" + redis_key = f'{self._redis_key_prefix}:{document_id}:updates' all_updates = await self._redis.lrange(redis_key, 0, -1) if len(all_updates) <= 1: return @@ -173,39 +177,39 @@ def _compact_updates_memory(self, document_id: str): self._updates[document_id] = [ydoc.get_update()] + updates[mid:] async def get_updates(self, document_id: str) -> List[bytes]: - document_id = document_id.replace(":", "_") + document_id = document_id.replace(':', '_') if self._redis: - redis_key = f"{self._redis_key_prefix}:{document_id}:updates" + redis_key = f'{self._redis_key_prefix}:{document_id}:updates' updates = await self._redis.lrange(redis_key, 0, -1) return [bytes(json.loads(update)) for update in updates] else: return self._updates.get(document_id, []) async def document_exists(self, document_id: str) -> bool: - document_id = document_id.replace(":", "_") + document_id = document_id.replace(':', '_') if self._redis: - redis_key = f"{self._redis_key_prefix}:{document_id}:updates" + redis_key = f'{self._redis_key_prefix}:{document_id}:updates' return await self._redis.exists(redis_key) > 0 else: return document_id in self._updates async def get_users(self, document_id: str) -> List[str]: - document_id = document_id.replace(":", "_") + document_id = document_id.replace(':', '_') if self._redis: - redis_key = f"{self._redis_key_prefix}:{document_id}:users" + redis_key = f'{self._redis_key_prefix}:{document_id}:users' users = await self._redis.smembers(redis_key) return list(users) else: return self._users.get(document_id, []) async def add_user(self, document_id: str, user_id: str): - document_id = document_id.replace(":", "_") + document_id = document_id.replace(':', '_') if self._redis: - redis_key = f"{self._redis_key_prefix}:{document_id}:users" + redis_key = f'{self._redis_key_prefix}:{document_id}:users' await self._redis.sadd(redis_key, user_id) else: if document_id not in self._users: @@ -213,10 +217,10 @@ async def add_user(self, document_id: str, user_id: str): self._users[document_id].add(user_id) async def remove_user(self, document_id: str, user_id: str): - document_id = document_id.replace(":", "_") + document_id = document_id.replace(':', '_') if self._redis: - redis_key = f"{self._redis_key_prefix}:{document_id}:users" + redis_key = f'{self._redis_key_prefix}:{document_id}:users' await self._redis.srem(redis_key, user_id) else: if document_id in self._users and user_id in self._users[document_id]: @@ -225,15 +229,13 @@ async def remove_user(self, document_id: str, user_id: str): async def remove_user_from_all_documents(self, user_id: str): if self._redis: keys = [] - async for key in self._redis.scan_iter( - match=f"{self._redis_key_prefix}:*", count=100 - ): + async for key in self._redis.scan_iter(match=f'{self._redis_key_prefix}:*', count=100): keys.append(key) for key in keys: - if key.endswith(":users"): + if key.endswith(':users'): await self._redis.srem(key, user_id) - document_id = key.split(":")[-2] + document_id = key.split(':')[-2] if len(await self.get_users(document_id)) == 0: await self.clear_document(document_id) @@ -247,12 +249,12 @@ async def remove_user_from_all_documents(self, user_id: str): await self.clear_document(document_id) async def clear_document(self, document_id: str): - document_id = document_id.replace(":", "_") + document_id = document_id.replace(':', '_') if self._redis: - redis_key = f"{self._redis_key_prefix}:{document_id}:updates" + redis_key = f'{self._redis_key_prefix}:{document_id}:updates' await self._redis.delete(redis_key) - redis_users_key = f"{self._redis_key_prefix}:{document_id}:users" + redis_users_key = f'{self._redis_key_prefix}:{document_id}:users' await self._redis.delete(redis_users_key) else: if document_id in self._updates: diff --git a/backend/open_webui/static/assets/pdf-style.css b/backend/open_webui/static/assets/pdf-style.css index 8b4e8d2370..644dd58ae6 100644 --- a/backend/open_webui/static/assets/pdf-style.css +++ b/backend/open_webui/static/assets/pdf-style.css @@ -25,7 +25,8 @@ } html { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR', + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR', 'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 14px; /* Default font size */ diff --git a/backend/open_webui/static/swagger-ui/swagger-ui-bundle.js b/backend/open_webui/static/swagger-ui/swagger-ui-bundle.js index dcd1c5313d..8656897872 100644 --- a/backend/open_webui/static/swagger-ui/swagger-ui-bundle.js +++ b/backend/open_webui/static/swagger-ui/swagger-ui-bundle.js @@ -14,7 +14,7 @@ i = { 69119: (s, o) => { 'use strict'; - Object.defineProperty(o, '__esModule', { value: !0 }), + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.BLANK_URL = o.relativeFirstCharacters = o.whitespaceEscapeCharsRegex = @@ -31,7 +31,7 @@ (o.urlSchemeRegex = /^.+(:|:)/gim), (o.whitespaceEscapeCharsRegex = /(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g), (o.relativeFirstCharacters = ['.', '/']), - (o.BLANK_URL = 'about:blank'); + (o.BLANK_URL = 'about:blank')); }, 16750: (s, o, i) => { 'use strict'; @@ -83,7 +83,7 @@ }, 67526: (s, o) => { 'use strict'; - (o.byteLength = function byteLength(s) { + ((o.byteLength = function byteLength(s) { var o = getLens(s), i = o[0], u = o[1]; @@ -103,14 +103,14 @@ L = 0, B = C > 0 ? x - 4 : x; for (i = 0; i < B; i += 4) - (o = + ((o = (u[s.charCodeAt(i)] << 18) | (u[s.charCodeAt(i + 1)] << 12) | (u[s.charCodeAt(i + 2)] << 6) | u[s.charCodeAt(i + 3)]), (j[L++] = (o >> 16) & 255), (j[L++] = (o >> 8) & 255), - (j[L++] = 255 & o); + (j[L++] = 255 & o)); 2 === C && ((o = (u[s.charCodeAt(i)] << 2) | (u[s.charCodeAt(i + 1)] >> 4)), (j[L++] = 255 & o)); @@ -136,7 +136,7 @@ ((o = (s[u - 2] << 8) + s[u - 1]), w.push(i[o >> 10] + i[(o >> 4) & 63] + i[(o << 2) & 63] + '=')); return w.join(''); - }); + })); for ( var i = [], u = [], @@ -146,20 +146,20 @@ x < 64; ++x ) - (i[x] = w[x]), (u[w.charCodeAt(x)] = x); + ((i[x] = w[x]), (u[w.charCodeAt(x)] = x)); function getLens(s) { var o = s.length; if (o % 4 > 0) throw new Error('Invalid string. Length must be a multiple of 4'); var i = s.indexOf('='); - return -1 === i && (i = o), [i, i === o ? 0 : 4 - (i % 4)]; + return (-1 === i && (i = o), [i, i === o ? 0 : 4 - (i % 4)]); } function encodeChunk(s, o, u) { for (var _, w, x = [], C = o; C < u; C += 3) - (_ = ((s[C] << 16) & 16711680) + ((s[C + 1] << 8) & 65280) + (255 & s[C + 2])), - x.push(i[((w = _) >> 18) & 63] + i[(w >> 12) & 63] + i[(w >> 6) & 63] + i[63 & w]); + ((_ = ((s[C] << 16) & 16711680) + ((s[C + 1] << 8) & 65280) + (255 & s[C + 2])), + x.push(i[((w = _) >> 18) & 63] + i[(w >> 12) & 63] + i[(w >> 6) & 63] + i[63 & w])); return x.join(''); } - (u['-'.charCodeAt(0)] = 62), (u['_'.charCodeAt(0)] = 63); + ((u['-'.charCodeAt(0)] = 62), (u['_'.charCodeAt(0)] = 63)); }, 48287: (s, o, i) => { 'use strict'; @@ -169,17 +169,17 @@ 'function' == typeof Symbol && 'function' == typeof Symbol.for ? Symbol.for('nodejs.util.inspect.custom') : null; - (o.Buffer = Buffer), + ((o.Buffer = Buffer), (o.SlowBuffer = function SlowBuffer(s) { +s != s && (s = 0); return Buffer.alloc(+s); }), - (o.INSPECT_MAX_BYTES = 50); + (o.INSPECT_MAX_BYTES = 50)); const x = 2147483647; function createBuffer(s) { if (s > x) throw new RangeError('The value "' + s + '" is invalid for option "size"'); const o = new Uint8Array(s); - return Object.setPrototypeOf(o, Buffer.prototype), o; + return (Object.setPrototypeOf(o, Buffer.prototype), o); } function Buffer(s, o, i) { if ('number' == typeof s) { @@ -232,7 +232,7 @@ if (Buffer.isBuffer(s)) { const o = 0 | checked(s.length), i = createBuffer(o); - return 0 === i.length || s.copy(i, 0, 0, o), i; + return (0 === i.length || s.copy(i, 0, 0, o), i); } if (void 0 !== s.length) return 'number' != typeof s.length || numberIsNaN(s.length) @@ -257,7 +257,7 @@ if (s < 0) throw new RangeError('The value "' + s + '" is invalid for option "size"'); } function allocUnsafe(s) { - return assertSize(s), createBuffer(s < 0 ? 0 : 0 | checked(s)); + return (assertSize(s), createBuffer(s < 0 ? 0 : 0 | checked(s))); } function fromArrayLike(s) { const o = s.length < 0 ? 0 : 0 | checked(s.length), @@ -323,7 +323,7 @@ return base64ToBytes(s).length; default: if (_) return u ? -1 : utf8ToBytes(s).length; - (o = ('' + o).toLowerCase()), (_ = !0); + ((o = ('' + o).toLowerCase()), (_ = !0)); } } function slowToString(s, o, i) { @@ -352,12 +352,12 @@ return utf16leSlice(this, o, i); default: if (u) throw new TypeError('Unknown encoding: ' + s); - (s = (s + '').toLowerCase()), (u = !0); + ((s = (s + '').toLowerCase()), (u = !0)); } } function swap(s, o, i) { const u = s[o]; - (s[o] = s[i]), (s[i] = u); + ((s[o] = s[i]), (s[i] = u)); } function bidirectionalIndexOf(s, o, i, u, _) { if (0 === s.length) return -1; @@ -403,7 +403,7 @@ 'utf-16le' === u) ) { if (s.length < 2 || o.length < 2) return -1; - (x = 2), (C /= 2), (j /= 2), (i /= 2); + ((x = 2), (C /= 2), (j /= 2), (i /= 2)); } function read(s, o) { return 1 === x ? s[o] : s.readUInt16BE(o * x); @@ -413,7 +413,7 @@ for (w = i; w < C; w++) if (read(s, w) === read(o, -1 === u ? 0 : w - u)) { if ((-1 === u && (u = w), w - u + 1 === j)) return u * x; - } else -1 !== u && (w -= w - u), (u = -1); + } else (-1 !== u && (w -= w - u), (u = -1)); } else for (i + j > C && (i = C - j), w = i; w >= 0; w--) { let i = !0; @@ -463,7 +463,7 @@ let i, u, _; const w = []; for (let x = 0; x < s.length && !((o -= 2) < 0); ++x) - (i = s.charCodeAt(x)), (u = i >> 8), (_ = i % 256), w.push(_), w.push(u); + ((i = s.charCodeAt(x)), (u = i >> 8), (_ = i % 256), w.push(_), w.push(u)); return w; })(o, s.length - i), s, @@ -489,34 +489,34 @@ o < 128 && (w = o); break; case 2: - (i = s[_ + 1]), - 128 == (192 & i) && ((j = ((31 & o) << 6) | (63 & i)), j > 127 && (w = j)); + ((i = s[_ + 1]), + 128 == (192 & i) && ((j = ((31 & o) << 6) | (63 & i)), j > 127 && (w = j))); break; case 3: - (i = s[_ + 1]), + ((i = s[_ + 1]), (u = s[_ + 2]), 128 == (192 & i) && 128 == (192 & u) && ((j = ((15 & o) << 12) | ((63 & i) << 6) | (63 & u)), - j > 2047 && (j < 55296 || j > 57343) && (w = j)); + j > 2047 && (j < 55296 || j > 57343) && (w = j))); break; case 4: - (i = s[_ + 1]), + ((i = s[_ + 1]), (u = s[_ + 2]), (C = s[_ + 3]), 128 == (192 & i) && 128 == (192 & u) && 128 == (192 & C) && ((j = ((15 & o) << 18) | ((63 & i) << 12) | ((63 & u) << 6) | (63 & C)), - j > 65535 && j < 1114112 && (w = j)); + j > 65535 && j < 1114112 && (w = j))); } } - null === w + (null === w ? ((w = 65533), (x = 1)) : w > 65535 && ((w -= 65536), u.push(((w >>> 10) & 1023) | 55296), (w = 56320 | (1023 & w))), u.push(w), - (_ += x); + (_ += x)); } return (function decodeCodePointsArray(s) { const o = s.length; @@ -527,7 +527,7 @@ return i; })(u); } - (o.kMaxLength = x), + ((o.kMaxLength = x), (Buffer.TYPED_ARRAY_SUPPORT = (function typedArraySupport() { try { const s = new Uint8Array(1), @@ -606,7 +606,7 @@ u = o.length; for (let _ = 0, w = Math.min(i, u); _ < w; ++_) if (s[_] !== o[_]) { - (i = s[_]), (u = o[_]); + ((i = s[_]), (u = o[_])); break; } return i < u ? -1 : u < i ? 1 : 0; @@ -663,17 +663,17 @@ (Buffer.prototype.swap32 = function swap32() { const s = this.length; if (s % 4 != 0) throw new RangeError('Buffer size must be a multiple of 32-bits'); - for (let o = 0; o < s; o += 4) swap(this, o, o + 3), swap(this, o + 1, o + 2); + for (let o = 0; o < s; o += 4) (swap(this, o, o + 3), swap(this, o + 1, o + 2)); return this; }), (Buffer.prototype.swap64 = function swap64() { const s = this.length; if (s % 8 != 0) throw new RangeError('Buffer size must be a multiple of 64-bits'); for (let o = 0; o < s; o += 8) - swap(this, o, o + 7), + (swap(this, o, o + 7), swap(this, o + 1, o + 6), swap(this, o + 2, o + 5), - swap(this, o + 3, o + 4); + swap(this, o + 3, o + 4)); return this; }), (Buffer.prototype.toString = function toString() { @@ -729,7 +729,7 @@ L = s.slice(o, i); for (let s = 0; s < C; ++s) if (j[s] !== L[s]) { - (w = j[s]), (x = L[s]); + ((w = j[s]), (x = L[s])); break; } return w < x ? -1 : x < w ? 1 : 0; @@ -744,17 +744,17 @@ return bidirectionalIndexOf(this, s, o, i, !1); }), (Buffer.prototype.write = function write(s, o, i, u) { - if (void 0 === o) (u = 'utf8'), (i = this.length), (o = 0); - else if (void 0 === i && 'string' == typeof o) (u = o), (i = this.length), (o = 0); + if (void 0 === o) ((u = 'utf8'), (i = this.length), (o = 0)); + else if (void 0 === i && 'string' == typeof o) ((u = o), (i = this.length), (o = 0)); else { if (!isFinite(o)) throw new Error( 'Buffer.write(string, encoding, offset[, length]) is no longer supported' ); - (o >>>= 0), + ((o >>>= 0), isFinite(i) ? ((i >>>= 0), void 0 === u && (u = 'utf8')) - : ((u = i), (i = void 0)); + : ((u = i), (i = void 0))); } const _ = this.length - o; if ( @@ -784,12 +784,12 @@ return ucs2Write(this, s, o, i); default: if (w) throw new TypeError('Unknown encoding: ' + u); - (u = ('' + u).toLowerCase()), (w = !0); + ((u = ('' + u).toLowerCase()), (w = !0)); } }), (Buffer.prototype.toJSON = function toJSON() { return { type: 'Buffer', data: Array.prototype.slice.call(this._arr || this, 0) }; - }); + })); const C = 4096; function asciiSlice(s, o, i) { let u = ''; @@ -805,7 +805,7 @@ } function hexSlice(s, o, i) { const u = s.length; - (!o || o < 0) && (o = 0), (!i || i < 0 || i > u) && (i = u); + ((!o || o < 0) && (o = 0), (!i || i < 0 || i > u) && (i = u)); let _ = ''; for (let u = o; u < i; ++u) _ += B[s[u]]; return _; @@ -830,7 +830,13 @@ function wrtBigUInt64LE(s, o, i, u, _) { checkIntBI(o, u, _, s, i, 7); let w = Number(o & BigInt(4294967295)); - (s[i++] = w), (w >>= 8), (s[i++] = w), (w >>= 8), (s[i++] = w), (w >>= 8), (s[i++] = w); + ((s[i++] = w), + (w >>= 8), + (s[i++] = w), + (w >>= 8), + (s[i++] = w), + (w >>= 8), + (s[i++] = w)); let x = Number((o >> BigInt(32)) & BigInt(4294967295)); return ( (s[i++] = x), @@ -846,13 +852,13 @@ function wrtBigUInt64BE(s, o, i, u, _) { checkIntBI(o, u, _, s, i, 7); let w = Number(o & BigInt(4294967295)); - (s[i + 7] = w), + ((s[i + 7] = w), (w >>= 8), (s[i + 6] = w), (w >>= 8), (s[i + 5] = w), (w >>= 8), - (s[i + 4] = w); + (s[i + 4] = w)); let x = Number((o >> BigInt(32)) & BigInt(4294967295)); return ( (s[i + 3] = x), @@ -871,25 +877,33 @@ } function writeFloat(s, o, i, u, w) { return ( - (o = +o), (i >>>= 0), w || checkIEEE754(s, 0, i, 4), _.write(s, o, i, u, 23, 4), i + 4 + (o = +o), + (i >>>= 0), + w || checkIEEE754(s, 0, i, 4), + _.write(s, o, i, u, 23, 4), + i + 4 ); } function writeDouble(s, o, i, u, w) { return ( - (o = +o), (i >>>= 0), w || checkIEEE754(s, 0, i, 8), _.write(s, o, i, u, 52, 8), i + 8 + (o = +o), + (i >>>= 0), + w || checkIEEE754(s, 0, i, 8), + _.write(s, o, i, u, 52, 8), + i + 8 ); } - (Buffer.prototype.slice = function slice(s, o) { + ((Buffer.prototype.slice = function slice(s, o) { const i = this.length; - (s = ~~s) < 0 ? (s += i) < 0 && (s = 0) : s > i && (s = i), + ((s = ~~s) < 0 ? (s += i) < 0 && (s = 0) : s > i && (s = i), (o = void 0 === o ? i : ~~o) < 0 ? (o += i) < 0 && (o = 0) : o > i && (o = i), - o < s && (o = s); + o < s && (o = s)); const u = this.subarray(s, o); - return Object.setPrototypeOf(u, Buffer.prototype), u; + return (Object.setPrototypeOf(u, Buffer.prototype), u); }), (Buffer.prototype.readUintLE = Buffer.prototype.readUIntLE = function readUIntLE(s, o, i) { - (s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length); + ((s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length)); let u = this[s], _ = 1, w = 0; @@ -898,7 +912,7 @@ }), (Buffer.prototype.readUintBE = Buffer.prototype.readUIntBE = function readUIntBE(s, o, i) { - (s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length); + ((s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length)); let u = this[s + --o], _ = 1; for (; o > 0 && (_ *= 256); ) u += this[s + --o] * _; @@ -906,18 +920,22 @@ }), (Buffer.prototype.readUint8 = Buffer.prototype.readUInt8 = function readUInt8(s, o) { - return (s >>>= 0), o || checkOffset(s, 1, this.length), this[s]; + return ((s >>>= 0), o || checkOffset(s, 1, this.length), this[s]); }), (Buffer.prototype.readUint16LE = Buffer.prototype.readUInt16LE = function readUInt16LE(s, o) { return ( - (s >>>= 0), o || checkOffset(s, 2, this.length), this[s] | (this[s + 1] << 8) + (s >>>= 0), + o || checkOffset(s, 2, this.length), + this[s] | (this[s + 1] << 8) ); }), (Buffer.prototype.readUint16BE = Buffer.prototype.readUInt16BE = function readUInt16BE(s, o) { return ( - (s >>>= 0), o || checkOffset(s, 2, this.length), (this[s] << 8) | this[s + 1] + (s >>>= 0), + o || checkOffset(s, 2, this.length), + (this[s] << 8) | this[s + 1] ); }), (Buffer.prototype.readUint32LE = Buffer.prototype.readUInt32LE = @@ -955,20 +973,20 @@ return (BigInt(u) << BigInt(32)) + BigInt(_); })), (Buffer.prototype.readIntLE = function readIntLE(s, o, i) { - (s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length); + ((s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length)); let u = this[s], _ = 1, w = 0; for (; ++w < o && (_ *= 256); ) u += this[s + w] * _; - return (_ *= 128), u >= _ && (u -= Math.pow(2, 8 * o)), u; + return ((_ *= 128), u >= _ && (u -= Math.pow(2, 8 * o)), u); }), (Buffer.prototype.readIntBE = function readIntBE(s, o, i) { - (s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length); + ((s >>>= 0), (o >>>= 0), i || checkOffset(s, o, this.length)); let u = o, _ = 1, w = this[s + --u]; for (; u > 0 && (_ *= 256); ) w += this[s + --u] * _; - return (_ *= 128), w >= _ && (w -= Math.pow(2, 8 * o)), w; + return ((_ *= 128), w >= _ && (w -= Math.pow(2, 8 * o)), w); }), (Buffer.prototype.readInt8 = function readInt8(s, o) { return ( @@ -978,12 +996,12 @@ ); }), (Buffer.prototype.readInt16LE = function readInt16LE(s, o) { - (s >>>= 0), o || checkOffset(s, 2, this.length); + ((s >>>= 0), o || checkOffset(s, 2, this.length)); const i = this[s] | (this[s + 1] << 8); return 32768 & i ? 4294901760 | i : i; }), (Buffer.prototype.readInt16BE = function readInt16BE(s, o) { - (s >>>= 0), o || checkOffset(s, 2, this.length); + ((s >>>= 0), o || checkOffset(s, 2, this.length)); const i = this[s + 1] | (this[s] << 8); return 32768 & i ? 4294901760 | i : i; }), @@ -1024,16 +1042,16 @@ ); })), (Buffer.prototype.readFloatLE = function readFloatLE(s, o) { - return (s >>>= 0), o || checkOffset(s, 4, this.length), _.read(this, s, !0, 23, 4); + return ((s >>>= 0), o || checkOffset(s, 4, this.length), _.read(this, s, !0, 23, 4)); }), (Buffer.prototype.readFloatBE = function readFloatBE(s, o) { - return (s >>>= 0), o || checkOffset(s, 4, this.length), _.read(this, s, !1, 23, 4); + return ((s >>>= 0), o || checkOffset(s, 4, this.length), _.read(this, s, !1, 23, 4)); }), (Buffer.prototype.readDoubleLE = function readDoubleLE(s, o) { - return (s >>>= 0), o || checkOffset(s, 8, this.length), _.read(this, s, !0, 52, 8); + return ((s >>>= 0), o || checkOffset(s, 8, this.length), _.read(this, s, !0, 52, 8)); }), (Buffer.prototype.readDoubleBE = function readDoubleBE(s, o) { - return (s >>>= 0), o || checkOffset(s, 8, this.length), _.read(this, s, !1, 52, 8); + return ((s >>>= 0), o || checkOffset(s, 8, this.length), _.read(this, s, !1, 52, 8)); }), (Buffer.prototype.writeUintLE = Buffer.prototype.writeUIntLE = function writeUIntLE(s, o, i, u) { @@ -1134,8 +1152,8 @@ w = 1, x = 0; for (this[o] = 255 & s; ++_ < i && (w *= 256); ) - s < 0 && 0 === x && 0 !== this[o + _ - 1] && (x = 1), - (this[o + _] = (((s / w) | 0) - x) & 255); + (s < 0 && 0 === x && 0 !== this[o + _ - 1] && (x = 1), + (this[o + _] = (((s / w) | 0) - x) & 255)); return o + i; }), (Buffer.prototype.writeIntBE = function writeIntBE(s, o, i, u) { @@ -1147,8 +1165,8 @@ w = 1, x = 0; for (this[o + _] = 255 & s; --_ >= 0 && (w *= 256); ) - s < 0 && 0 === x && 0 !== this[o + _ + 1] && (x = 1), - (this[o + _] = (((s / w) | 0) - x) & 255); + (s < 0 && 0 === x && 0 !== this[o + _ + 1] && (x = 1), + (this[o + _] = (((s / w) | 0) - x) & 255)); return o + i; }), (Buffer.prototype.writeInt8 = function writeInt8(s, o, i) { @@ -1257,7 +1275,8 @@ if (o < 0) throw new RangeError('targetStart out of bounds'); if (i < 0 || i >= this.length) throw new RangeError('Index out of range'); if (u < 0) throw new RangeError('sourceEnd out of bounds'); - u > this.length && (u = this.length), s.length - o < u - i && (u = s.length - o + i); + (u > this.length && (u = this.length), + s.length - o < u - i && (u = s.length - o + i)); const _ = u - i; return ( this === s && 'function' == typeof Uint8Array.prototype.copyWithin @@ -1301,12 +1320,12 @@ for (_ = 0; _ < i - o; ++_) this[_ + o] = w[_ % x]; } return this; - }); + })); const j = {}; function E(s, o, i) { j[s] = class NodeError extends i { constructor() { - super(), + (super(), Object.defineProperty(this, 'message', { value: o.apply(this, arguments), writable: !0, @@ -1314,7 +1333,7 @@ }), (this.name = `${this.name} [${s}]`), this.stack, - delete this.name; + delete this.name); } get code() { return s; @@ -1344,18 +1363,18 @@ const u = 'bigint' == typeof o ? 'n' : ''; let _; throw ( - ((_ = + (_ = w > 3 ? 0 === o || o === BigInt(0) ? `>= 0${u} and < 2${u} ** ${8 * (w + 1)}${u}` : `>= -(2${u} ** ${8 * (w + 1) - 1}${u}) and < 2 ** ${8 * (w + 1) - 1}${u}` : `>= ${o}${u} and <= ${i}${u}`), - new j.ERR_OUT_OF_RANGE('value', _, s)) + new j.ERR_OUT_OF_RANGE('value', _, s) ); } !(function checkBounds(s, o, i) { - validateNumber(o, 'offset'), - (void 0 !== s[o] && void 0 !== s[o + i]) || boundsError(o, s.length - (i + 1)); + (validateNumber(o, 'offset'), + (void 0 !== s[o] && void 0 !== s[o + i]) || boundsError(o, s.length - (i + 1))); })(u, _, w); } function validateNumber(s, o) { @@ -1367,7 +1386,7 @@ if (o < 0) throw new j.ERR_BUFFER_OUT_OF_BOUNDS(); throw new j.ERR_OUT_OF_RANGE(i || 'offset', `>= ${i ? 1 : 0} and <= ${o}`, s); } - E( + (E( 'ERR_BUFFER_OUT_OF_BOUNDS', function (s) { return s @@ -1401,7 +1420,7 @@ ); }, RangeError - ); + )); const L = /[^+/0-9A-Za-z-_]/g; function utf8ToBytes(s, o) { let i; @@ -1424,7 +1443,7 @@ continue; } if (i < 56320) { - (o -= 3) > -1 && w.push(239, 191, 189), (_ = i); + ((o -= 3) > -1 && w.push(239, 191, 189), (_ = i)); continue; } i = 65536 + (((_ - 55296) << 10) | (i - 56320)); @@ -1505,7 +1524,7 @@ j, L, B = !1; - o || (o = {}), (i = o.debug || !1); + (o || (o = {}), (i = o.debug || !1)); try { if ( ((x = u()), @@ -1525,12 +1544,12 @@ L.addEventListener('copy', function (u) { if ((u.stopPropagation(), o.format)) if ((u.preventDefault(), void 0 === u.clipboardData)) { - i && console.warn('unable to use e.clipboardData'), + (i && console.warn('unable to use e.clipboardData'), i && console.warn('trying IE specific stuff'), - window.clipboardData.clearData(); + window.clipboardData.clearData()); var w = _[o.format] || _.default; window.clipboardData.setData(w, s); - } else u.clipboardData.clearData(), u.clipboardData.setData(o.format, s); + } else (u.clipboardData.clearData(), u.clipboardData.setData(o.format, s)); o.onCopy && (u.preventDefault(), o.onCopy(u.clipboardData)); }), document.body.appendChild(L), @@ -1541,32 +1560,32 @@ throw new Error('copy command was unsuccessful'); B = !0; } catch (u) { - i && console.error('unable to copy using execCommand: ', u), - i && console.warn('trying IE specific stuff'); + (i && console.error('unable to copy using execCommand: ', u), + i && console.warn('trying IE specific stuff')); try { - window.clipboardData.setData(o.format || 'text', s), + (window.clipboardData.setData(o.format || 'text', s), o.onCopy && o.onCopy(window.clipboardData), - (B = !0); + (B = !0)); } catch (u) { - i && console.error('unable to copy using clipboardData: ', u), + (i && console.error('unable to copy using clipboardData: ', u), i && console.error('falling back to prompt'), (w = (function format(s) { var o = (/mac os x/i.test(navigator.userAgent) ? 'โŒ˜' : 'Ctrl') + '+C'; return s.replace(/#{\s*key\s*}/g, o); })('message' in o ? o.message : 'Copy to clipboard: #{key}, Enter')), - window.prompt(w, s); + window.prompt(w, s)); } } finally { - j && ('function' == typeof j.removeRange ? j.removeRange(C) : j.removeAllRanges()), + (j && ('function' == typeof j.removeRange ? j.removeRange(C) : j.removeAllRanges()), L && document.body.removeChild(L), - x(); + x()); } return B; }; }, 2205: function (s, o, i) { var u; - (u = void 0 !== i.g ? i.g : this), + ((u = void 0 !== i.g ? i.g : this), (s.exports = (function (s) { if (s.CSS && s.CSS.escape) return s.CSS.escape; var cssEscape = function (s) { @@ -1575,7 +1594,6 @@ for ( var o, i = String(s), u = i.length, _ = -1, w = '', x = i.charCodeAt(0); ++_ < u; - ) 0 != (o = i.charCodeAt(_)) ? (w += @@ -1598,8 +1616,8 @@ : (w += '๏ฟฝ'); return w; }; - return s.CSS || (s.CSS = {}), (s.CSS.escape = cssEscape), cssEscape; - })(u)); + return (s.CSS || (s.CSS = {}), (s.CSS.escape = cssEscape), cssEscape); + })(u))); }, 81919: (s, o, i) => { 'use strict'; @@ -1610,7 +1628,7 @@ function cloneSpecificValue(s) { if (s instanceof u) { var o = u.alloc ? u.alloc(s.length) : new u(s.length); - return s.copy(o), o; + return (s.copy(o), o); } if (s instanceof Date) return new Date(s.getTime()); if (s instanceof RegExp) return new RegExp(s); @@ -1746,9 +1764,9 @@ ); } function deepmerge(s, i, u) { - ((u = u || {}).arrayMerge = u.arrayMerge || defaultArrayMerge), + (((u = u || {}).arrayMerge = u.arrayMerge || defaultArrayMerge), (u.isMergeableObject = u.isMergeableObject || o), - (u.cloneUnlessOtherwiseSpecified = cloneUnlessOtherwiseSpecified); + (u.cloneUnlessOtherwiseSpecified = cloneUnlessOtherwiseSpecified)); var _ = Array.isArray(i); return _ === Array.isArray(s) ? _ @@ -1777,7 +1795,7 @@ } = Object; let { freeze: w, seal: x, create: C } = Object, { apply: j, construct: L } = 'undefined' != typeof Reflect && Reflect; - w || + (w || (w = function freeze(s) { return s; }), @@ -1792,7 +1810,7 @@ L || (L = function construct(s, o) { return new s(...o); - }); + })); const B = unapply(Array.prototype.forEach), $ = unapply(Array.prototype.pop), V = unapply(Array.prototype.push), @@ -2533,7 +2551,10 @@ try { return s.createPolicy(_, { createHTML: (s) => s, createScriptURL: (s) => s }); } catch (s) { - return console.warn('TrustedTypes policy ' + _ + ' could not be created.'), null; + return ( + console.warn('TrustedTypes policy ' + _ + ' could not be created.'), + null + ); } }; function createDOMPurify() { @@ -2544,7 +2565,7 @@ (DOMPurify.removed = []), !o || !o.document || o.document.nodeType !== rt.document) ) - return (DOMPurify.isSupported = !1), DOMPurify; + return ((DOMPurify.isSupported = !1), DOMPurify); let { document: i } = o; const u = i, _ = u.currentScript, @@ -2779,11 +2800,11 @@ throw ce( 'TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.' ); - (ct = s.TRUSTED_TYPES_POLICY), (ut = ct.createHTML('')); + ((ct = s.TRUSTED_TYPES_POLICY), (ut = ct.createHTML(''))); } else - void 0 === ct && (ct = st(Ye, _)), - null !== ct && 'string' == typeof ut && (ut = ct.createHTML('')); - w && w(s), (yr = s); + (void 0 === ct && (ct = st(Ye, _)), + null !== ct && 'string' == typeof ut && (ut = ct.createHTML(''))); + (w && w(s), (yr = s)); } }, Er = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']), @@ -2910,7 +2931,7 @@ }, Nr = function _sanitizeElements(s) { let o = null; - if ((Tr('beforeSanitizeElements', s, null), Pr(s))) return Or(s), !0; + if ((Tr('beforeSanitizeElements', s, null), Pr(s))) return (Or(s), !0); const i = gr(s.nodeName); if ( (Tr('uponSanitizeElement', s, { tagName: i, allowedTags: Ot }), @@ -2919,9 +2940,9 @@ le(/<[/\w]/g, s.innerHTML) && le(/<[/\w]/g, s.textContent)) ) - return Or(s), !0; - if (s.nodeType === rt.progressingInstruction) return Or(s), !0; - if (Ft && s.nodeType === rt.comment && le(/<[/\w]/g, s.data)) return Or(s), !0; + return (Or(s), !0); + if (s.nodeType === rt.progressingInstruction) return (Or(s), !0); + if (Ft && s.nodeType === rt.comment && le(/<[/\w]/g, s.data)) return (Or(s), !0); if (!Ot[i] || Mt[i]) { if (!Mt[i] && Dr(i)) { if (Pt.tagNameCheck instanceof RegExp && le(Pt.tagNameCheck, i)) return !1; @@ -2933,11 +2954,11 @@ if (i && o) for (let u = i.length - 1; u >= 0; --u) { const _ = et(i[u], !0); - (_.__removalCount = (s.__removalCount || 0) + 1), - o.insertBefore(_, it(s)); + ((_.__removalCount = (s.__removalCount || 0) + 1), + o.insertBefore(_, it(s))); } } - return Or(s), !0; + return (Or(s), !0); } return s instanceof Re && !Cr(s) ? (Or(s), !0) @@ -3041,8 +3062,8 @@ L = ct.createScriptURL(L); } try { - x ? s.setAttributeNS(x, w, L) : s.setAttribute(w, L), - Pr(s) ? Or(s) : $(DOMPurify.removed); + (x ? s.setAttributeNS(x, w, L) : s.setAttribute(w, L), + Pr(s) ? Or(s) : $(DOMPurify.removed)); } catch (s) {} } } @@ -3052,8 +3073,8 @@ let o = null; const i = Ir(s); for (Tr('beforeSanitizeShadowDOM', s, null); (o = i.nextNode()); ) - Tr('uponSanitizeShadowNode', o, null), - Nr(o) || (o.content instanceof x && _sanitizeShadowDOM(o.content), Lr(o)); + (Tr('uponSanitizeShadowNode', o, null), + Nr(o) || (o.content instanceof x && _sanitizeShadowDOM(o.content), Lr(o))); Tr('afterSanitizeShadowDOM', s, null); }; return ( @@ -3078,11 +3099,11 @@ throw ce('root node is forbidden and cannot be sanitized in-place'); } } else if (s instanceof L) - (i = jr('\x3c!----\x3e')), + ((i = jr('\x3c!----\x3e')), (_ = i.ownerDocument.importNode(s, !0)), (_.nodeType === rt.element && 'BODY' === _.nodeName) || 'HTML' === _.nodeName ? (i = _) - : i.appendChild(_); + : i.appendChild(_)); else { if (!Ut && !Bt && !qt && -1 === s.indexOf('<')) return ct && Wt ? ct.createHTML(s) : s; @@ -3098,7 +3119,7 @@ for (C = dt.call(i.ownerDocument); i.firstChild; ) C.appendChild(i.firstChild); else C = i; - return (jt.shadowroot || jt.shadowrootmode) && (C = gt.call(u, C, !0)), C; + return ((jt.shadowroot || jt.shadowrootmode) && (C = gt.call(u, C, !0)), C); } let $ = qt ? i.outerHTML : i.innerHTML; return ( @@ -3117,11 +3138,11 @@ ); }), (DOMPurify.setConfig = function () { - _r(arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}), - ($t = !0); + (_r(arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}), + ($t = !0)); }), (DOMPurify.clearConfig = function () { - (yr = null), ($t = !1); + ((yr = null), ($t = !1)); }), (DOMPurify.isValidAttribute = function (s, o, i) { yr || _r({}); @@ -3151,7 +3172,7 @@ 'use strict'; class SubRange { constructor(s, o) { - (this.low = s), (this.high = o), (this.length = 1 + o - s); + ((this.low = s), (this.high = o), (this.length = 1 + o - s)); } overlaps(s) { return !(this.high < s.low || this.low > s.high); @@ -3177,7 +3198,7 @@ } class DRange { constructor(s, o) { - (this.ranges = []), (this.length = 0), null != s && this.add(s, o); + ((this.ranges = []), (this.length = 0), null != s && this.add(s, o)); } _update_length() { this.length = this.ranges.reduce((s, o) => s + o.length, 0); @@ -3188,10 +3209,9 @@ for ( var i = this.ranges.slice(0, o); o < this.ranges.length && s.touches(this.ranges[o]); - ) - (s = s.add(this.ranges[o])), o++; - i.push(s), (this.ranges = i.concat(this.ranges.slice(o))), this._update_length(); + ((s = s.add(this.ranges[o])), o++); + (i.push(s), (this.ranges = i.concat(this.ranges.slice(o))), this._update_length()); }; return ( s instanceof DRange @@ -3206,10 +3226,9 @@ for ( var i = this.ranges.slice(0, o); o < this.ranges.length && s.overlaps(this.ranges[o]); - ) - (i = i.concat(this.ranges[o].subtract(s))), o++; - (this.ranges = i.concat(this.ranges.slice(o))), this._update_length(); + ((i = i.concat(this.ranges[o].subtract(s))), o++); + ((this.ranges = i.concat(this.ranges.slice(o))), this._update_length()); }; return ( s instanceof DRange @@ -3225,7 +3244,7 @@ for (; o < this.ranges.length && s.overlaps(this.ranges[o]); ) { var u = Math.max(this.ranges[o].low, s.low), _ = Math.min(this.ranges[o].high, s.high); - i.push(new SubRange(u, _)), o++; + (i.push(new SubRange(u, _)), o++); } }; return ( @@ -3239,7 +3258,7 @@ } index(s) { for (var o = 0; o < this.ranges.length && this.ranges[o].length <= s; ) - (s -= this.ranges[o].length), o++; + ((s -= this.ranges[o].length), o++); return this.ranges[o].low + s; } toString() { @@ -3250,7 +3269,7 @@ } numbers() { return this.ranges.reduce((s, o) => { - for (var i = o.low; i <= o.high; ) s.push(i), i++; + for (var i = o.low; i <= o.high; ) (s.push(i), i++); return s; }, []); } @@ -3292,27 +3311,28 @@ function EventEmitter() { EventEmitter.init.call(this); } - (s.exports = EventEmitter), + ((s.exports = EventEmitter), (s.exports.once = function once(s, o) { return new Promise(function (i, u) { function errorListener(i) { - s.removeListener(o, resolver), u(i); + (s.removeListener(o, resolver), u(i)); } function resolver() { - 'function' == typeof s.removeListener && s.removeListener('error', errorListener), - i([].slice.call(arguments)); + ('function' == typeof s.removeListener && + s.removeListener('error', errorListener), + i([].slice.call(arguments))); } - eventTargetAgnosticAddListener(s, o, resolver, { once: !0 }), + (eventTargetAgnosticAddListener(s, o, resolver, { once: !0 }), 'error' !== o && (function addErrorHandlerIfEventEmitter(s, o, i) { 'function' == typeof s.on && eventTargetAgnosticAddListener(s, 'error', o, i); - })(s, errorListener, { once: !0 }); + })(s, errorListener, { once: !0 })); }); }), (EventEmitter.EventEmitter = EventEmitter), (EventEmitter.prototype._events = void 0), (EventEmitter.prototype._eventsCount = 0), - (EventEmitter.prototype._maxListeners = void 0); + (EventEmitter.prototype._maxListeners = void 0)); var w = 10; function checkListener(s) { if ('function' != typeof s) @@ -3334,7 +3354,7 @@ (x = w[o])), void 0 === x) ) - (x = w[o] = i), ++s._eventsCount; + ((x = w[o] = i), ++s._eventsCount); else if ( ('function' == typeof x ? (x = w[o] = u ? [i, x] : [x, i]) @@ -3351,13 +3371,13 @@ String(o) + ' listeners added. Use emitter.setMaxListeners() to increase limit' ); - (C.name = 'MaxListenersExceededWarning'), + ((C.name = 'MaxListenersExceededWarning'), (C.emitter = s), (C.type = o), (C.count = x.length), (function ProcessEmitWarning(s) { console && console.warn && console.warn(s); - })(C); + })(C)); } return s; } @@ -3374,7 +3394,7 @@ function _onceWrap(s, o, i) { var u = { fired: !1, wrapFn: void 0, target: s, type: o, listener: i }, _ = onceWrapper.bind(u); - return (_.listener = i), (u.wrapFn = _), _; + return ((_.listener = i), (u.wrapFn = _), _); } function _listeners(s, o, i) { var u = s._events; @@ -3415,11 +3435,11 @@ 'The "emitter" argument must be of type EventEmitter. Received type ' + typeof s ); s.addEventListener(o, function wrapListener(_) { - u.once && s.removeEventListener(o, wrapListener), i(_); + (u.once && s.removeEventListener(o, wrapListener), i(_)); }); } } - Object.defineProperty(EventEmitter, 'defaultMaxListeners', { + (Object.defineProperty(EventEmitter, 'defaultMaxListeners', { enumerable: !0, get: function () { return w; @@ -3435,9 +3455,9 @@ } }), (EventEmitter.init = function () { - (void 0 !== this._events && this._events !== Object.getPrototypeOf(this)._events) || + ((void 0 !== this._events && this._events !== Object.getPrototypeOf(this)._events) || ((this._events = Object.create(null)), (this._eventsCount = 0)), - (this._maxListeners = this._maxListeners || void 0); + (this._maxListeners = this._maxListeners || void 0)); }), (EventEmitter.prototype.setMaxListeners = function setMaxListeners(s) { if ('number' != typeof s || s < 0 || _(s)) @@ -3446,7 +3466,7 @@ s + '.' ); - return (this._maxListeners = s), this; + return ((this._maxListeners = s), this); }), (EventEmitter.prototype.getMaxListeners = function getMaxListeners() { return _getMaxListeners(this); @@ -3481,10 +3501,10 @@ return _addListener(this, s, o, !0); }), (EventEmitter.prototype.once = function once(s, o) { - return checkListener(o), this.on(s, _onceWrap(this, s, o)), this; + return (checkListener(o), this.on(s, _onceWrap(this, s, o)), this); }), (EventEmitter.prototype.prependOnceListener = function prependOnceListener(s, o) { - return checkListener(o), this.prependListener(s, _onceWrap(this, s, o)), this; + return (checkListener(o), this.prependListener(s, _onceWrap(this, s, o)), this); }), (EventEmitter.prototype.removeListener = function removeListener(s, o) { var i, u, _, w, x; @@ -3498,18 +3518,18 @@ else if ('function' != typeof i) { for (_ = -1, w = i.length - 1; w >= 0; w--) if (i[w] === o || i[w].listener === o) { - (x = i[w].listener), (_ = w); + ((x = i[w].listener), (_ = w)); break; } if (_ < 0) return this; - 0 === _ + (0 === _ ? i.shift() : (function spliceOne(s, o) { for (; o + 1 < s.length; o++) s[o] = s[o + 1]; s.pop(); })(i, _), 1 === i.length && (u[s] = i[0]), - void 0 !== u.removeListener && this.emit('removeListener', s, x || o); + void 0 !== u.removeListener && this.emit('removeListener', s, x || o)); } return this; }), @@ -3558,26 +3578,26 @@ (EventEmitter.prototype.listenerCount = listenerCount), (EventEmitter.prototype.eventNames = function eventNames() { return this._eventsCount > 0 ? o(this._events) : []; - }); + })); }, 85587: (s, o, i) => { 'use strict'; var u = i(26311), _ = create(Error); function create(s) { - return (FormattedError.displayName = s.displayName || s.name), FormattedError; + return ((FormattedError.displayName = s.displayName || s.name), FormattedError); function FormattedError(o) { - return o && (o = u.apply(null, arguments)), new s(o); + return (o && (o = u.apply(null, arguments)), new s(o)); } } - (s.exports = _), + ((s.exports = _), (_.eval = create(EvalError)), (_.range = create(RangeError)), (_.reference = create(ReferenceError)), (_.syntax = create(SyntaxError)), (_.type = create(TypeError)), (_.uri = create(URIError)), - (_.create = create); + (_.create = create)); }, 26311: (s) => { !(function () { @@ -3599,7 +3619,7 @@ return x[w++]; }, slurpNumber = function () { - for (var i = ''; /\d/.test(s[C]); ) (i += s[C++]), (o = s[C]); + for (var i = ''; /\d/.test(s[C]); ) ((i += s[C++]), (o = s[C])); return i.length > 0 ? parseInt(i) : null; }; C < j; @@ -3629,8 +3649,8 @@ L += parseInt(nextArg(), 10); break; case 'f': - (u = String(parseFloat(nextArg()).toFixed(_ || 6))), - (L += $ ? u : u.replace(/^0/, '')); + ((u = String(parseFloat(nextArg()).toFixed(_ || 6))), + (L += $ ? u : u.replace(/^0/, ''))); break; case 'j': L += JSON.stringify(nextArg()); @@ -3653,7 +3673,7 @@ else '%' === o ? (B = !0) : (L += o); return L; } - ((o = s.exports = format).format = format), + (((o = s.exports = format).format = format), (o.vsprintf = function vsprintf(s, o) { return format.apply(null, [s].concat(o)); }), @@ -3661,7 +3681,7 @@ 'function' == typeof console.log && (o.printf = function printf() { console.log(format.apply(null, arguments)); - }); + })); })(); }, 45981: (s) => { @@ -3694,7 +3714,9 @@ o.default = i; class Response { constructor(s) { - void 0 === s.data && (s.data = {}), (this.data = s.data), (this.isMatchIgnored = !1); + (void 0 === s.data && (s.data = {}), + (this.data = s.data), + (this.isMatchIgnored = !1)); } ignoreMatch() { this.isMatchIgnored = !0; @@ -3721,7 +3743,7 @@ const emitsWrappingTags = (s) => !!s.kind; class HTMLRenderer { constructor(s, o) { - (this.buffer = ''), (this.classPrefix = o.classPrefix), s.walk(this); + ((this.buffer = ''), (this.classPrefix = o.classPrefix), s.walk(this)); } addText(s) { this.buffer += escapeHTML(s); @@ -3729,7 +3751,7 @@ openNode(s) { if (!emitsWrappingTags(s)) return; let o = s.kind; - s.sublanguage || (o = `${this.classPrefix}${o}`), this.span(o); + (s.sublanguage || (o = `${this.classPrefix}${o}`), this.span(o)); } closeNode(s) { emitsWrappingTags(s) && (this.buffer += ''); @@ -3743,7 +3765,7 @@ } class TokenTree { constructor() { - (this.rootNode = { children: [] }), (this.stack = [this.rootNode]); + ((this.rootNode = { children: [] }), (this.stack = [this.rootNode])); } get top() { return this.stack[this.stack.length - 1]; @@ -3756,7 +3778,7 @@ } openNode(s) { const o = { kind: s, children: [] }; - this.add(o), this.stack.push(o); + (this.add(o), this.stack.push(o)); } closeNode() { if (this.stack.length > 1) return this.stack.pop(); @@ -3791,7 +3813,7 @@ } class TokenTreeEmitter extends TokenTree { constructor(s) { - super(), (this.options = s); + (super(), (this.options = s)); } addKeyword(s, o) { '' !== s && (this.openNode(o), this.addText(s), this.closeNode()); @@ -3801,7 +3823,7 @@ } addSublanguage(s, o) { const i = s.root; - (i.kind = o), (i.sublanguage = !0), this.add(i); + ((i.kind = o), (i.sublanguage = !0), this.add(i)); } toHTML() { return new HTMLRenderer(this, this.options).value(); @@ -3945,7 +3967,7 @@ function compileMatch(s, o) { if (s.match) { if (s.begin || s.end) throw new Error('begin & end are not supported with match'); - (s.begin = s.match), delete s.match; + ((s.begin = s.match), delete s.match); } } function compileRelevance(s, o) { @@ -3977,11 +3999,11 @@ u ); function compileList(s, i) { - o && (i = i.map((s) => s.toLowerCase())), + (o && (i = i.map((s) => s.toLowerCase())), i.forEach(function (o) { const i = o.split('|'); u[i[0]] = [s, scoreForKeyword(i[0], i[1])]; - }); + })); } } function scoreForKeyword(s, o) { @@ -3999,24 +4021,24 @@ } class MultiRegex { constructor() { - (this.matchIndexes = {}), + ((this.matchIndexes = {}), (this.regexes = []), (this.matchAt = 1), - (this.position = 0); + (this.position = 0)); } addRule(s, o) { - (o.position = this.position++), + ((o.position = this.position++), (this.matchIndexes[this.matchAt] = o), this.regexes.push([o, s]), (this.matchAt += (function countMatchGroups(s) { return new RegExp(s.toString() + '|').exec('').length - 1; - })(s) + 1); + })(s) + 1)); } compile() { 0 === this.regexes.length && (this.exec = () => null); const s = this.regexes.map((s) => s[1]); - (this.matcherRe = langRe( + ((this.matcherRe = langRe( (function join(s, o = '|') { let i = 0; return s @@ -4031,11 +4053,11 @@ w += _; break; } - (w += _.substring(0, s.index)), + ((w += _.substring(0, s.index)), (_ = _.substring(s.index + s[0].length)), '\\' === s[0][0] && s[1] ? (w += '\\' + String(Number(s[1]) + o)) - : ((w += s[0]), '(' === s[0] && i++); + : ((w += s[0]), '(' === s[0] && i++)); } return w; }) @@ -4044,7 +4066,7 @@ })(s), !0 )), - (this.lastIndex = 0); + (this.lastIndex = 0)); } exec(s) { this.matcherRe.lastIndex = this.lastIndex; @@ -4052,16 +4074,16 @@ if (!o) return null; const i = o.findIndex((s, o) => o > 0 && void 0 !== s), u = this.matchIndexes[i]; - return o.splice(0, i), Object.assign(o, u); + return (o.splice(0, i), Object.assign(o, u)); } } class ResumableMultiRegex { constructor() { - (this.rules = []), + ((this.rules = []), (this.multiRegexes = []), (this.count = 0), (this.lastIndex = 0), - (this.regexIndex = 0); + (this.regexIndex = 0)); } getMatcher(s) { if (this.multiRegexes[s]) return this.multiRegexes[s]; @@ -4080,7 +4102,7 @@ this.regexIndex = 0; } addRule(s, o) { - this.rules.push([s, o]), 'begin' === o.type && this.count++; + (this.rules.push([s, o]), 'begin' === o.type && this.count++); } exec(s) { const o = this.getMatcher(this.regexIndex); @@ -4090,7 +4112,7 @@ if (i && i.index === this.lastIndex); else { const o = this.getMatcher(0); - (o.lastIndex = this.lastIndex + 1), (i = o.exec(s)); + ((o.lastIndex = this.lastIndex + 1), (i = o.exec(s))); } return ( i && @@ -4112,11 +4134,11 @@ (function compileMode(o, i) { const u = o; if (o.isCompiled) return u; - [compileMatch].forEach((s) => s(o, i)), + ([compileMatch].forEach((s) => s(o, i)), s.compilerExtensions.forEach((s) => s(o, i)), (o.__beforeBegin = null), [beginKeywords, compileIllegal, compileRelevance].forEach((s) => s(o, i)), - (o.isCompiled = !0); + (o.isCompiled = !0)); let _ = null; if ( ('object' == typeof o.keywords && @@ -4237,7 +4259,7 @@ const u = nodeStream(s); if (!u.length) return; const _ = document.createElement('div'); - (_.innerHTML = o.value), + ((_.innerHTML = o.value), (o.value = (function mergeStreams(s, o, i) { let u = 0, _ = ''; @@ -4274,15 +4296,15 @@ ) { w.reverse().forEach(close); do { - render(o.splice(0, 1)[0]), (o = selectStream()); + (render(o.splice(0, 1)[0]), (o = selectStream())); } while (o === s && o.length && o[0].offset === u); w.reverse().forEach(open); } else - 'start' === o[0].event ? w.push(o[0].node) : w.pop(), - render(o.splice(0, 1)[0]); + ('start' === o[0].event ? w.push(o[0].node) : w.pop(), + render(o.splice(0, 1)[0])); } return _ + escapeHTML(i.substr(u)); - })(u, nodeStream(_), i)); + })(u, nodeStream(_), i))); } }; function tag(s) { @@ -4355,7 +4377,7 @@ const x = { code: _, language: w }; fire('before:highlight', x); const C = x.result ? x.result : _highlight(x.language, x.code, i, u); - return (C.code = x.code), fire('after:highlight', C), C; + return ((C.code = x.code), fire('after:highlight', C), C); } function _highlight(s, o, u, x) { function keywordData(s, o) { @@ -4363,17 +4385,17 @@ return Object.prototype.hasOwnProperty.call(s.keywords, i) && s.keywords[i]; } function processBuffer() { - null != U.subLanguage + (null != U.subLanguage ? (function processSubLanguage() { if ('' === Z) return; let s = null; if ('string' == typeof U.subLanguage) { if (!i[U.subLanguage]) return void Y.addText(Z); - (s = _highlight(U.subLanguage, Z, !0, z[U.subLanguage])), - (z[U.subLanguage] = s.top); + ((s = _highlight(U.subLanguage, Z, !0, z[U.subLanguage])), + (z[U.subLanguage] = s.top)); } else s = highlightAuto(Z, U.subLanguage.length ? U.subLanguage : null); - U.relevance > 0 && (ee += s.relevance), - Y.addSublanguage(s.emitter, s.language); + (U.relevance > 0 && (ee += s.relevance), + Y.addSublanguage(s.emitter, s.language)); })() : (function processKeywords() { if (!U.keywords) return void Y.addText(Z); @@ -4392,11 +4414,11 @@ Y.addKeyword(o[0], i); } } else i += o[0]; - (s = U.keywordPatternRe.lastIndex), (o = U.keywordPatternRe.exec(Z)); + ((s = U.keywordPatternRe.lastIndex), (o = U.keywordPatternRe.exec(Z))); } - (i += Z.substr(s)), Y.addText(i); + ((i += Z.substr(s)), Y.addText(i)); })(), - (Z = ''); + (Z = '')); } function startNewMode(s) { return ( @@ -4413,7 +4435,7 @@ if (u) { if (s['on:end']) { const i = new Response(s); - s['on:end'](o, i), i.isMatchIgnored && (u = !1); + (s['on:end'](o, i), i.isMatchIgnored && (u = !1)); } if (u) { for (; s.endsParent && s.parent; ) s = s.parent; @@ -4458,9 +4480,9 @@ processBuffer(), w.excludeEnd && (Z = i)); do { - U.className && Y.closeNode(), + (U.className && Y.closeNode(), U.skip || U.subLanguage || (ee += U.relevance), - (U = U.parent); + (U = U.parent)); } while (U !== _.parent); return ( _.starts && @@ -4471,7 +4493,7 @@ let j = {}; function processLexeme(i, _) { const x = _ && _[0]; - if (((Z += i), null == x)) return processBuffer(), 0; + if (((Z += i), null == x)) return (processBuffer(), 0); if ('begin' === j.type && 'end' === _.type && j.index === _.index && '' === x) { if (((Z += o.slice(_.index, _.index + 1)), !w)) { const o = new Error('0 width match regex'); @@ -4494,7 +4516,7 @@ if (ae > 1e5 && ae > 3 * _.index) { throw new Error('potential infinite loop, way more iterations than matches'); } - return (Z += x), x.length; + return ((Z += x), x.length); } const B = getLanguage(s); if (!B) throw (error(C.replace('{}', s)), new Error('Unknown language: "' + s + '"')); @@ -4515,7 +4537,7 @@ le = !1; try { for (U.matcher.considerAll(); ; ) { - ae++, le ? (le = !1) : U.matcher.considerAll(), (U.matcher.lastIndex = ie); + (ae++, le ? (le = !1) : U.matcher.considerAll(), (U.matcher.lastIndex = ie)); const s = U.matcher.exec(o); if (!s) break; const i = processLexeme(o.substring(ie, s.index), s); @@ -4572,7 +4594,7 @@ illegal: !1, top: j }; - return o.emitter.addText(s), o; + return (o.emitter.addText(s), o); })(s), _ = o .filter(getLanguage) @@ -4589,7 +4611,7 @@ }), [x, C] = w, B = x; - return (B.second_best = C), B; + return ((B.second_best = C), B); } const B = { 'before:highlightElement': ({ el: s }) => { @@ -4625,14 +4647,14 @@ return o.split(/\s+/).find((s) => shouldNotHighlight(s) || getLanguage(s)); })(s); if (shouldNotHighlight(i)) return; - fire('before:highlightElement', { el: s, language: i }), (o = s); + (fire('before:highlightElement', { el: s, language: i }), (o = s)); const _ = o.textContent, w = i ? highlight(_, { language: i, ignoreIllegals: !0 }) : highlightAuto(_); - fire('after:highlightElement', { el: s, result: w, text: _ }), + (fire('after:highlightElement', { el: s, result: w, text: _ }), (s.innerHTML = w.value), (function updateClassName(s, o, i) { const _ = o ? u[o] : i; - s.classList.add('hljs'), _ && s.classList.add(_); + (s.classList.add('hljs'), _ && s.classList.add(_)); })(s, i, w.language), (s.result = { language: w.language, re: w.relevance, relavance: w.relevance }), w.second_best && @@ -4640,15 +4662,15 @@ language: w.second_best.language, re: w.second_best.relevance, relavance: w.second_best.relevance - }); + })); } const initHighlighting = () => { if (initHighlighting.called) return; - (initHighlighting.called = !0), + ((initHighlighting.called = !0), deprecated( '10.6.0', 'initHighlighting() is deprecated. Use highlightAll() instead.' - ); + )); document.querySelectorAll('pre code').forEach(highlightElement); }; let U = !1; @@ -4657,13 +4679,13 @@ document.querySelectorAll('pre code').forEach(highlightElement); } function getLanguage(s) { - return (s = (s || '').toLowerCase()), i[s] || i[u[s]]; + return ((s = (s || '').toLowerCase()), i[s] || i[u[s]]); } function registerAliases(s, { languageName: o }) { - 'string' == typeof s && (s = [s]), + ('string' == typeof s && (s = [s]), s.forEach((s) => { u[s.toLowerCase()] = o; - }); + })); } function autoDetection(s) { const o = getLanguage(s); @@ -4675,7 +4697,7 @@ s[i] && s[i](o); }); } - 'undefined' != typeof window && + ('undefined' != typeof window && window.addEventListener && window.addEventListener( 'DOMContentLoaded', @@ -4719,21 +4741,21 @@ ); }, configure: function configure(s) { - s.useBR && + (s.useBR && (deprecated('10.3.0', "'useBR' will be removed entirely in v11.0"), deprecated( '10.3.0', 'Please see https://github.com/highlightjs/highlight.js/issues/2559' )), - (L = Se(L, s)); + (L = Se(L, s))); }, initHighlighting, initHighlightingOnLoad: function initHighlightingOnLoad() { - deprecated( + (deprecated( '10.6.0', 'initHighlightingOnLoad() is deprecated. Use highlightAll() instead.' ), - (U = !0); + (U = !0)); }, registerLanguage: function registerLanguage(o, u) { let _ = null; @@ -4747,12 +4769,12 @@ !w) ) throw s; - error(s), (_ = j); + (error(s), (_ = j)); } - _.name || (_.name = o), + (_.name || (_.name = o), (i[o] = _), (_.rawDefinition = u.bind(null, s)), - _.aliases && registerAliases(_.aliases, { languageName: o }); + _.aliases && registerAliases(_.aliases, { languageName: o })); }, unregisterLanguage: function unregisterLanguage(s) { delete i[s]; @@ -4764,11 +4786,11 @@ getLanguage, registerAliases, requireLanguage: function requireLanguage(s) { - deprecated('10.4.0', 'requireLanguage will be removed entirely in v11.'), + (deprecated('10.4.0', 'requireLanguage will be removed entirely in v11.'), deprecated( '10.4.0', 'Please see https://github.com/highlightjs/highlight.js/pull/2844' - ); + )); const o = getLanguage(s); if (o) return o; throw new Error( @@ -4778,8 +4800,8 @@ autoDetection, inherit: Se, addPlugin: function addPlugin(s) { - !(function upgradePluginAPI(s) { - s['before:highlightBlock'] && + (!(function upgradePluginAPI(s) { + (s['before:highlightBlock'] && !s['before:highlightElement'] && (s['before:highlightElement'] = (o) => { s['before:highlightBlock'](Object.assign({ block: o.el }, o)); @@ -4788,9 +4810,9 @@ !s['after:highlightElement'] && (s['after:highlightElement'] = (o) => { s['after:highlightBlock'](Object.assign({ block: o.el }, o)); - }); + })); })(s), - _.push(s); + _.push(s)); }, vuePlugin: BuildVuePlugin(s).VuePlugin }), @@ -4800,9 +4822,9 @@ (s.safeMode = function () { w = !0; }), - (s.versionString = '10.7.3'); + (s.versionString = '10.7.3')); for (const s in fe) 'object' == typeof fe[s] && o(fe[s]); - return Object.assign(s, fe), s.addPlugin(B), s.addPlugin(be), s.addPlugin(V), s; + return (Object.assign(s, fe), s.addPlugin(B), s.addPlugin(be), s.addPlugin(V), s); })({}); s.exports = Pe; }, @@ -5712,7 +5734,7 @@ }; }, 251: (s, o) => { - (o.read = function (s, o, i, u, _) { + ((o.read = function (s, o, i, u, _) { var w, x, C = 8 * _ - u - 1, @@ -5735,7 +5757,7 @@ if (0 === w) w = 1 - L; else { if (w === j) return x ? NaN : (1 / 0) * (U ? -1 : 1); - (x += Math.pow(2, u)), (w -= L); + ((x += Math.pow(2, u)), (w -= L)); } return (U ? -1 : 1) * x * Math.pow(2, w - u); }), @@ -5768,14 +5790,14 @@ ); for (x = (x << _) | C, L += _; L > 0; s[i + U] = 255 & x, U += z, x /= 256, L -= 8); s[i + U - z] |= 128 * Y; - }); + })); }, 9404: function (s) { s.exports = (function () { 'use strict'; var s = Array.prototype.slice; function createClass(s, o) { - o && (s.prototype = Object.create(o.prototype)), (s.prototype.constructor = s); + (o && (s.prototype = Object.create(o.prototype)), (s.prototype.constructor = s)); } function Iterable(s) { return isIterable(s) ? s : Seq(s); @@ -5804,7 +5826,7 @@ function isOrdered(s) { return !(!s || !s[_]); } - createClass(KeyedIterable, Iterable), + (createClass(KeyedIterable, Iterable), createClass(IndexedIterable, Iterable), createClass(SetIterable, Iterable), (Iterable.isIterable = isIterable), @@ -5814,7 +5836,7 @@ (Iterable.isOrdered = isOrdered), (Iterable.Keyed = KeyedIterable), (Iterable.Indexed = IndexedIterable), - (Iterable.Set = SetIterable); + (Iterable.Set = SetIterable)); var o = '@@__IMMUTABLE_ITERABLE__@@', i = '@@__IMMUTABLE_KEYED__@@', u = '@@__IMMUTABLE_INDEXED__@@', @@ -5827,7 +5849,7 @@ B = { value: !1 }, $ = { value: !1 }; function MakeRef(s) { - return (s.value = !1), s; + return ((s.value = !1), s); } function SetRef(s) { s && (s.value = !0); @@ -5840,7 +5862,7 @@ return u; } function ensureSize(s) { - return void 0 === s.size && (s.size = s.__iterate(returnTrue)), s.size; + return (void 0 === s.size && (s.size = s.__iterate(returnTrue)), s.size); } function wrapIndex(s, o) { if ('number' != typeof o) { @@ -5884,7 +5906,7 @@ } function iteratorValue(s, o, i, u) { var _ = 0 === s ? o : 1 === s ? i : [o, i]; - return u ? (u.value = _) : (u = { value: _, done: !1 }), u; + return (u ? (u.value = _) : (u = { value: _, done: !1 }), u); } function iteratorDone() { return { value: void 0, done: !0 }; @@ -5938,7 +5960,7 @@ : indexedSeqFromValue(s) ).toSetSeq(); } - (Iterator.prototype.toString = function () { + ((Iterator.prototype.toString = function () { return '[Iterator]'; }), (Iterator.KEYS = V), @@ -6005,23 +6027,23 @@ (Seq.isSeq = isSeq), (Seq.Keyed = KeyedSeq), (Seq.Set = SetSeq), - (Seq.Indexed = IndexedSeq); + (Seq.Indexed = IndexedSeq)); var ie, ae, le, ce = '@@__IMMUTABLE_SEQ__@@'; function ArraySeq(s) { - (this._array = s), (this.size = s.length); + ((this._array = s), (this.size = s.length)); } function ObjectSeq(s) { var o = Object.keys(s); - (this._object = s), (this._keys = o), (this.size = o.length); + ((this._object = s), (this._keys = o), (this.size = o.length)); } function IterableSeq(s) { - (this._iterable = s), (this.size = s.length || s.size); + ((this._iterable = s), (this.size = s.length || s.size)); } function IteratorSeq(s) { - (this._iterator = s), (this._iteratorCache = []); + ((this._iterator = s), (this._iteratorCache = [])); } function isSeq(s) { return !(!s || !s[ce]); @@ -6163,12 +6185,12 @@ else { _ = !0; var w = s; - (s = o), (o = w); + ((s = o), (o = w)); } var x = !0, C = o.__iterate(function (o, u) { if (i ? !s.has(o) : _ ? !is(o, s.get(u, L)) : !is(s.get(u, L), o)) - return (x = !1), !1; + return ((x = !1), !1); }); return x && s.size === C; } @@ -6210,7 +6232,7 @@ function KeyedCollection() {} function IndexedCollection() {} function SetCollection() {} - (Seq.prototype[ce] = !0), + ((Seq.prototype[ce] = !0), createClass(ArraySeq, IndexedSeq), (ArraySeq.prototype.get = function (s, o) { return this.has(s) ? this._array[wrapIndex(this, s)] : o; @@ -6397,7 +6419,7 @@ w = 0; return new Iterator(function () { var x = _; - return (_ += o ? -u : u), w > i ? iteratorDone() : iteratorValue(s, w++, x); + return ((_ += o ? -u : u), w > i ? iteratorDone() : iteratorValue(s, w++, x)); }); }), (Range.prototype.equals = function (s) { @@ -6411,7 +6433,7 @@ createClass(SetCollection, Collection), (Collection.Keyed = KeyedCollection), (Collection.Indexed = IndexedCollection), - (Collection.Set = SetCollection); + (Collection.Set = SetCollection)); var pe = 'function' == typeof Math.imul && -2 === Math.imul(4294967295, 2) ? Math.imul @@ -6476,10 +6498,10 @@ void 0 !== s.propertyIsEnumerable && s.propertyIsEnumerable === s.constructor.prototype.propertyIsEnumerable ) - (s.propertyIsEnumerable = function () { + ((s.propertyIsEnumerable = function () { return this.constructor.prototype.propertyIsEnumerable.apply(this, arguments); }), - (s.propertyIsEnumerable[we] = o); + (s.propertyIsEnumerable[we] = o)); else { if (void 0 === s.nodeType) throw new Error('Unable to set a non-enumerable property on object.'); @@ -6491,7 +6513,7 @@ var de = Object.isExtensible, fe = (function () { try { - return Object.defineProperty({}, '@', {}), !0; + return (Object.defineProperty({}, '@', {}), !0); } catch (s) { return !1; } @@ -6525,16 +6547,16 @@ ? s : emptyMap().withMutations(function (o) { var i = KeyedIterable(s); - assertNotInfinite(i.size), + (assertNotInfinite(i.size), i.forEach(function (s, i) { return o.set(i, s); - }); + })); }); } function isMap(s) { return !(!s || !s[qe]); } - createClass(Map, KeyedCollection), + (createClass(Map, KeyedCollection), (Map.of = function () { var o = s.call(arguments, 0); return emptyMap().withMutations(function (s) { @@ -6620,7 +6642,7 @@ }), (Map.prototype.withMutations = function (s) { var o = this.asMutable(); - return s(o), o.wasAltered() ? o.__ensureOwner(this.__ownerID) : this; + return (s(o), o.wasAltered() ? o.__ensureOwner(this.__ownerID) : this); }), (Map.prototype.asMutable = function () { return this.__ownerID ? this : this.__ensureOwner(new OwnerID()); @@ -6640,7 +6662,7 @@ return ( this._root && this._root.iterate(function (o) { - return u++, s(o[1], o[0], i); + return (u++, s(o[1], o[0], i)); }, o), u ); @@ -6652,29 +6674,29 @@ ? makeMap(this.size, this._root, s, this.__hash) : ((this.__ownerID = s), (this.__altered = !1), this); }), - (Map.isMap = isMap); + (Map.isMap = isMap)); var Re, qe = '@@__IMMUTABLE_MAP__@@', $e = Map.prototype; function ArrayMapNode(s, o) { - (this.ownerID = s), (this.entries = o); + ((this.ownerID = s), (this.entries = o)); } function BitmapIndexedNode(s, o, i) { - (this.ownerID = s), (this.bitmap = o), (this.nodes = i); + ((this.ownerID = s), (this.bitmap = o), (this.nodes = i)); } function HashArrayMapNode(s, o, i) { - (this.ownerID = s), (this.count = o), (this.nodes = i); + ((this.ownerID = s), (this.count = o), (this.nodes = i)); } function HashCollisionNode(s, o, i) { - (this.ownerID = s), (this.keyHash = o), (this.entries = i); + ((this.ownerID = s), (this.keyHash = o), (this.entries = i)); } function ValueNode(s, o, i) { - (this.ownerID = s), (this.keyHash = o), (this.entry = i); + ((this.ownerID = s), (this.keyHash = o), (this.entry = i)); } function MapIterator(s, o, i) { - (this._type = o), + ((this._type = o), (this._reverse = i), - (this._stack = s._root && mapIteratorFrame(s._root)); + (this._stack = s._root && mapIteratorFrame(s._root))); } function mapIteratorValue(s, o) { return iteratorValue(s, o[0], o[1]); @@ -6706,7 +6728,7 @@ _ = s.size + (w.value ? (i === L ? -1 : 1) : 0); } else { if (i === L) return s; - (_ = 1), (u = new ArrayMapNode(s.__ownerID, [[o, i]])); + ((_ = 1), (u = new ArrayMapNode(s.__ownerID, [[o, i]]))); } return s.__ownerID ? ((s.size = _), (s._root = u), (s.__hash = void 0), (s.__altered = !0), s) @@ -6759,17 +6781,17 @@ function expandNodes(s, o, i, u, _) { for (var w = 0, x = new Array(C), j = 0; 0 !== i; j++, i >>>= 1) x[j] = 1 & i ? o[w++] : void 0; - return (x[u] = _), new HashArrayMapNode(s, w + 1, x); + return ((x[u] = _), new HashArrayMapNode(s, w + 1, x)); } function mergeIntoMapWith(s, o, i) { for (var u = [], _ = 0; _ < i.length; _++) { var w = i[_], x = KeyedIterable(w); - isIterable(w) || + (isIterable(w) || (x = x.map(function (s) { return fromJS(s); })), - u.push(x); + u.push(x)); } return mergeIntoCollectionWith(s, o, u); } @@ -6835,23 +6857,23 @@ } function setIn(s, o, i, u) { var _ = u ? s : arrCopy(s); - return (_[o] = i), _; + return ((_[o] = i), _); } function spliceIn(s, o, i, u) { var _ = s.length + 1; - if (u && o + 1 === _) return (s[o] = i), s; + if (u && o + 1 === _) return ((s[o] = i), s); for (var w = new Array(_), x = 0, C = 0; C < _; C++) C === o ? ((w[C] = i), (x = -1)) : (w[C] = s[C + x]); return w; } function spliceOut(s, o, i) { var u = s.length - 1; - if (i && o === u) return s.pop(), s; + if (i && o === u) return (s.pop(), s); for (var _ = new Array(u), w = 0, x = 0; x < u; x++) - x === o && (w = 1), (_[x] = s[x + w]); + (x === o && (w = 1), (_[x] = s[x + w])); return _; } - ($e[qe] = !0), + (($e[qe] = !0), ($e[w] = $e.remove), ($e.removeIn = $e.deleteIn), (ArrayMapNode.prototype.get = function (s, o, i, u) { @@ -7022,7 +7044,7 @@ o = this._stack = this._stack.__prev; } return iteratorDone(); - }); + })); var ze = C / 4, We = C / 2, He = C / 4; @@ -7038,16 +7060,16 @@ u > 0 && u < C ? makeList(0, u, x, null, new VNode(i.toArray())) : o.withMutations(function (s) { - s.setSize(u), + (s.setSize(u), i.forEach(function (o, i) { return s.set(i, o); - }); + })); })); } function isList(s) { return !(!s || !s[Ye]); } - createClass(List, IndexedCollection), + (createClass(List, IndexedCollection), (List.of = function () { return this(arguments); }), @@ -7143,7 +7165,6 @@ for ( var i, u = 0, _ = iterateList(this, o); (i = _()) !== tt && !1 !== s(i, u++, this); - ); return u; }), @@ -7162,13 +7183,13 @@ ) : ((this.__ownerID = s), this); }), - (List.isList = isList); + (List.isList = isList)); var Ye = '@@__IMMUTABLE_LIST__@@', Xe = List.prototype; function VNode(s, o) { - (this.array = s), (this.ownerID = o); + ((this.array = s), (this.ownerID = o)); } - (Xe[Ye] = !0), + ((Xe[Ye] = !0), (Xe[w] = Xe.remove), (Xe.setIn = $e.setIn), (Xe.deleteIn = Xe.removeIn = $e.removeIn), @@ -7193,7 +7214,7 @@ if (w && !_) return this; var L = editableVNode(this, s); if (!w) for (var B = 0; B < u; B++) L.array[B] = void 0; - return _ && (L.array[u] = _), L; + return (_ && (L.array[u] = _), L); }), (VNode.prototype.removeAfter = function (s, o, i) { if (i === (o ? 1 << o : 0) || 0 === this.array.length) return this; @@ -7206,8 +7227,8 @@ return this; } var C = editableVNode(this, s); - return C.array.splice(_ + 1), u && (C.array[_] = u), C; - }); + return (C.array.splice(_ + 1), u && (C.array[_] = u), C); + })); var Qe, et, tt = {}; @@ -7318,12 +7339,12 @@ if (o >= getTailOffset(s._capacity)) return s._tail; if (o < 1 << (s._level + x)) { for (var i = s._root, u = s._level; i && u > 0; ) - (i = i.array[(o >>> u) & j]), (u -= x); + ((i = i.array[(o >>> u) & j]), (u -= x)); return i; } } function setListBounds(s, o, i) { - void 0 !== o && (o |= 0), void 0 !== i && (i |= 0); + (void 0 !== o && (o |= 0), void 0 !== i && (i |= 0)); var u = s.__ownerID || new OwnerID(), _ = s._origin, w = s._capacity, @@ -7332,10 +7353,10 @@ if (C === _ && L === w) return s; if (C >= L) return s.clear(); for (var B = s._level, $ = s._root, V = 0; C + V < 0; ) - ($ = new VNode($ && $.array.length ? [void 0, $] : [], u)), (V += 1 << (B += x)); + (($ = new VNode($ && $.array.length ? [void 0, $] : [], u)), (V += 1 << (B += x))); V && ((C += V), (_ += V), (L += V), (w += V)); for (var U = getTailOffset(w), z = getTailOffset(L); z >= 1 << (B + x); ) - ($ = new VNode($ && $.array.length ? [$] : [], u)), (B += x); + (($ = new VNode($ && $.array.length ? [$] : [], u)), (B += x)); var Y = s._tail, Z = z < U ? listNodeFor(s, L - 1) : z > U ? new VNode([], u) : Y; if (Y && z > U && C < w && Y.array.length) { @@ -7346,16 +7367,16 @@ ee.array[(U >>> x) & j] = Y; } if ((L < w && (Z = Z && Z.removeAfter(u, 0, L)), C >= z)) - (C -= z), (L -= z), (B = x), ($ = null), (Z = Z && Z.removeBefore(u, 0, C)); + ((C -= z), (L -= z), (B = x), ($ = null), (Z = Z && Z.removeBefore(u, 0, C))); else if (C > _ || z < U) { for (V = 0; $; ) { var le = (C >>> B) & j; if ((le !== z >>> B) & j) break; - le && (V += (1 << B) * le), (B -= x), ($ = $.array[le]); + (le && (V += (1 << B) * le), (B -= x), ($ = $.array[le])); } - $ && C > _ && ($ = $.removeBefore(u, B, C - V)), + ($ && C > _ && ($ = $.removeBefore(u, B, C - V)), $ && z < U && ($ = $.removeAfter(u, B, z - V)), - V && ((C -= V), (L -= V)); + V && ((C -= V), (L -= V))); } return s.__ownerID ? ((s.size = L - C), @@ -7373,14 +7394,14 @@ for (var u = [], _ = 0, w = 0; w < i.length; w++) { var x = i[w], C = IndexedIterable(x); - C.size > _ && (_ = C.size), + (C.size > _ && (_ = C.size), isIterable(x) || (C = C.map(function (s) { return fromJS(s); })), - u.push(C); + u.push(C)); } - return _ > s.size && (s = s.setSize(_)), mergeIntoCollectionWith(s, o, u); + return (_ > s.size && (s = s.setSize(_)), mergeIntoCollectionWith(s, o, u)); } function getTailOffset(s) { return s < C ? 0 : ((s - 1) >>> x) << x; @@ -7392,10 +7413,10 @@ ? s : emptyOrderedMap().withMutations(function (o) { var i = KeyedIterable(s); - assertNotInfinite(i.size), + (assertNotInfinite(i.size), i.forEach(function (s, i) { return o.set(i, s); - }); + })); }); } function isOrderedMap(s) { @@ -7438,23 +7459,23 @@ : ((u = w.remove(o)), (_ = j === x.size - 1 ? x.pop() : x.set(j, void 0))); } else if (B) { if (i === x.get(j)[1]) return s; - (u = w), (_ = x.set(j, [o, i])); - } else (u = w.set(o, x.size)), (_ = x.set(x.size, [o, i])); + ((u = w), (_ = x.set(j, [o, i]))); + } else ((u = w.set(o, x.size)), (_ = x.set(x.size, [o, i]))); return s.__ownerID ? ((s.size = u.size), (s._map = u), (s._list = _), (s.__hash = void 0), s) : makeOrderedMap(u, _); } function ToKeyedSequence(s, o) { - (this._iter = s), (this._useKeys = o), (this.size = s.size); + ((this._iter = s), (this._useKeys = o), (this.size = s.size)); } function ToIndexedSequence(s) { - (this._iter = s), (this.size = s.size); + ((this._iter = s), (this.size = s.size)); } function ToSetSequence(s) { - (this._iter = s), (this.size = s.size); + ((this._iter = s), (this.size = s.size)); } function FromEntriesSequence(s) { - (this._iter = s), (this.size = s.size); + ((this._iter = s), (this.size = s.size)); } function flipFactory(s) { var o = makeSequence(s); @@ -7493,7 +7514,7 @@ var s = u.next(); if (!s.done) { var o = s.value[0]; - (s.value[0] = s.value[1]), (s.value[1] = o); + ((s.value[0] = s.value[1]), (s.value[1] = o)); } return s; }); @@ -7590,7 +7611,7 @@ C = 0; return ( s.__iterate(function (s, w, j) { - if (o.call(i, s, w, j)) return C++, _(s, u ? w : C - 1, x); + if (o.call(i, s, w, j)) return (C++, _(s, u ? w : C - 1, x)); }, w), C ); @@ -7628,7 +7649,7 @@ _ = (isOrdered(s) ? OrderedMap() : Map()).asMutable(); s.__iterate(function (w, x) { _.update(o.call(i, w, x, s), function (s) { - return (s = s || []).push(u ? [x, w] : w), s; + return ((s = s || []).push(u ? [x, w] : w), s); }); }); var w = iterableClass(s); @@ -7669,7 +7690,7 @@ return ( s.__iterate(function (s, i) { if (!j || !(j = x++ < w)) - return L++, !1 !== o(s, u ? i : L - 1, _) && L !== C; + return (L++, !1 !== o(s, u ? i : L - 1, _) && L !== C); }), L ); @@ -7737,7 +7758,7 @@ j = 0; return ( s.__iterate(function (s, w, L) { - if (!C || !(C = o.call(i, s, w, L))) return j++, _(s, u ? w : j - 1, x); + if (!C || !(C = o.call(i, s, w, L))) return (j++, _(s, u ? w : j - 1, x)); }), j ); @@ -7756,7 +7777,7 @@ ? s : iteratorValue(_, L++, _ === V ? void 0 : s.value[1], s); var $ = s.value; - (w = $[0]), (B = $[1]), j && (j = o.call(i, B, w, x)); + ((w = $[0]), (B = $[1]), j && (j = o.call(i, B, w, x))); } while (j); return _ === z ? s : iteratorValue(_, w, B, s); }); @@ -7815,7 +7836,7 @@ ); }, _); } - return flatDeep(s, 0), w; + return (flatDeep(s, 0), w); }), (u.__iteratorUncached = function (u, _) { var w = s.__iterator(u, _), @@ -7828,7 +7849,7 @@ var j = s.value; if ((u === z && (j = j[1]), (o && !(x.length < o)) || !isIterable(j))) return i ? s : iteratorValue(u, C++, j, s); - x.push(w), (w = j.__iterator(u, _)); + (x.push(w), (w = j.__iterator(u, _))); } else w = x.pop(); } return iteratorDone(); @@ -7934,13 +7955,12 @@ for ( var i, u = this.__iterator(U, o), _ = 0; !(i = u.next()).done && !1 !== s(i.value, _++, this); - ); return _; }), (u.__iteratorUncached = function (s, u) { var _ = i.map(function (s) { - return (s = Iterable(s)), getIterator(u ? s.reverse() : s); + return ((s = Iterable(s)), getIterator(u ? s.reverse() : s)); }), w = 0, x = !1; @@ -7979,7 +7999,7 @@ if (s !== Object(s)) throw new TypeError('Expected [K, V] tuple: ' + s); } function resolveSize(s) { - return assertNotInfinite(s.size), ensureSize(s); + return (assertNotInfinite(s.size), ensureSize(s)); } function iterableClass(s) { return isKeyed(s) ? KeyedIterable : isIndexed(s) ? IndexedIterable : SetIterable; @@ -8013,18 +8033,18 @@ if (!i) { i = !0; var x = Object.keys(s); - setProps(_, x), + (setProps(_, x), (_.size = x.length), (_._name = o), (_._keys = x), - (_._defaultValues = s); + (_._defaultValues = s)); } this._map = Map(w); }, _ = (u.prototype = Object.create(rt)); - return (_.constructor = u), u; + return ((_.constructor = u), u); } - createClass(OrderedMap, Map), + (createClass(OrderedMap, Map), (OrderedMap.of = function () { return this(arguments); }), @@ -8211,7 +8231,7 @@ return this._map ? this._map.get(s, i) : i; }), (Record.prototype.clear = function () { - if (this.__ownerID) return this._map && this._map.clear(), this; + if (this.__ownerID) return (this._map && this._map.clear(), this); var s = this.constructor; return s._empty || (s._empty = makeRecord(this, emptyMap())); }), @@ -8250,11 +8270,11 @@ if (s === this.__ownerID) return this; var o = this._map && this._map.__ensureOwner(s); return s ? makeRecord(this, o, s) : ((this.__ownerID = s), (this._map = o), this); - }); + })); var rt = Record.prototype; function makeRecord(s, o, i) { var u = Object.create(Object.getPrototypeOf(s)); - return (u._map = o), (u.__ownerID = i), u; + return ((u._map = o), (u.__ownerID = i), u); } function recordName(s) { return s._name || s.constructor.name || 'Record'; @@ -8270,7 +8290,7 @@ return this.get(o); }, set: function (s) { - invariant(this.__ownerID, 'Cannot set on an immutable record.'), this.set(o, s); + (invariant(this.__ownerID, 'Cannot set on an immutable record.'), this.set(o, s)); } }); } @@ -8281,16 +8301,16 @@ ? s : emptySet().withMutations(function (o) { var i = SetIterable(s); - assertNotInfinite(i.size), + (assertNotInfinite(i.size), i.forEach(function (s) { return o.add(s); - }); + })); }); } function isSet(s) { return !(!s || !s[st]); } - (rt[w] = rt.remove), + ((rt[w] = rt.remove), (rt.deleteIn = rt.removeIn = $e.removeIn), (rt.merge = $e.merge), (rt.mergeWith = $e.mergeWith), @@ -8406,7 +8426,7 @@ var o = this._map.__ensureOwner(s); return s ? this.__make(o, s) : ((this.__ownerID = s), (this._map = o), this); }), - (Set.isSet = isSet); + (Set.isSet = isSet)); var nt, st = '@@__IMMUTABLE_SET__@@', ot = Set.prototype; @@ -8421,7 +8441,7 @@ } function makeSet(s, o) { var i = Object.create(ot); - return (i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i; + return ((i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i); } function emptySet() { return nt || (nt = makeSet(emptyMap())); @@ -8433,16 +8453,16 @@ ? s : emptyOrderedSet().withMutations(function (o) { var i = SetIterable(s); - assertNotInfinite(i.size), + (assertNotInfinite(i.size), i.forEach(function (s) { return o.add(s); - }); + })); }); } function isOrderedSet(s) { return isSet(s) && isOrdered(s); } - (ot[st] = !0), + ((ot[st] = !0), (ot[w] = ot.remove), (ot.mergeDeep = ot.merge), (ot.mergeDeepWith = ot.mergeWith), @@ -8461,12 +8481,12 @@ (OrderedSet.prototype.toString = function () { return this.__toString('OrderedSet {', '}'); }), - (OrderedSet.isOrderedSet = isOrderedSet); + (OrderedSet.isOrderedSet = isOrderedSet)); var it, at = OrderedSet.prototype; function makeOrderedSet(s, o) { var i = Object.create(at); - return (i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i; + return ((i.size = s ? s.size : 0), (i._map = s), (i.__ownerID = o), i); } function emptyOrderedSet() { return it || (it = makeOrderedSet(emptyOrderedMap())); @@ -8477,7 +8497,7 @@ function isStack(s) { return !(!s || !s[ct]); } - (at[_] = !0), + ((at[_] = !0), (at.__empty = emptyOrderedSet), (at.__make = makeOrderedSet), createClass(Stack, IndexedCollection), @@ -8518,7 +8538,7 @@ i = this._head; return ( s.reverse().forEach(function (s) { - o++, (i = { value: s, next: i }); + (o++, (i = { value: s, next: i })); }), this.__ownerID ? ((this.size = o), @@ -8585,12 +8605,12 @@ return new Iterator(function () { if (u) { var o = u.value; - return (u = u.next), iteratorValue(s, i++, o); + return ((u = u.next), iteratorValue(s, i++, o)); } return iteratorDone(); }); }), - (Stack.isStack = isStack); + (Stack.isStack = isStack)); var lt, ct = '@@__IMMUTABLE_STACK__@@', ut = Stack.prototype; @@ -8618,7 +8638,7 @@ s ); } - (ut[ct] = !0), + ((ut[ct] = !0), (ut.withMutations = $e.withMutations), (ut.asMutable = $e.asMutable), (ut.asImmutable = $e.asImmutable), @@ -8717,7 +8737,7 @@ var i = !0; return ( this.__iterate(function (u, _, w) { - if (!s.call(o, u, _, w)) return (i = !1), !1; + if (!s.call(o, u, _, w)) return ((i = !1), !1); }), i ); @@ -8730,15 +8750,15 @@ return u ? u[1] : i; }, forEach: function (s, o) { - return assertNotInfinite(this.size), this.__iterate(o ? s.bind(o) : s); + return (assertNotInfinite(this.size), this.__iterate(o ? s.bind(o) : s)); }, join: function (s) { - assertNotInfinite(this.size), (s = void 0 !== s ? '' + s : ','); + (assertNotInfinite(this.size), (s = void 0 !== s ? '' + s : ',')); var o = '', i = !0; return ( this.__iterate(function (u) { - i ? (i = !1) : (o += s), (o += null != u ? u.toString() : ''); + (i ? (i = !1) : (o += s), (o += null != u ? u.toString() : '')); }), o ); @@ -8816,7 +8836,7 @@ var u = i; return ( this.__iterate(function (i, _, w) { - if (s.call(o, i, _, w)) return (u = [_, i]), !1; + if (s.call(o, i, _, w)) return ((u = [_, i]), !1); }), u ); @@ -8944,9 +8964,9 @@ hashCode: function () { return this.__hash || (this.__hash = hashIterable(this)); } - }); + })); var pt = Iterable.prototype; - (pt[o] = !0), + ((pt[o] = !0), (pt[ee] = pt.values), (pt.__toJS = pt.toArray), (pt.__toStringMapper = quoteString), @@ -8984,7 +9004,7 @@ .flip() ); } - }); + })); var ht = KeyedIterable.prototype; function keyMapper(s, o) { return o; @@ -9129,7 +9149,7 @@ var s = [this].concat(arrCopy(arguments)), o = zipWithFactory(this.toSeq(), IndexedSeq.of, s), i = o.flatten(!0); - return o.size && (i.size = o.size * s.length), reify(this, i); + return (o.size && (i.size = o.size * s.length), reify(this, i)); }, keySeq: function () { return Range(0, this.size); @@ -9148,7 +9168,7 @@ }, zipWith: function (s) { var o = arrCopy(arguments); - return (o[0] = this), reify(this, zipWithFactory(this, s, o)); + return ((o[0] = this), reify(this, zipWithFactory(this, s, o))); } }), (IndexedIterable.prototype[u] = !0), @@ -9204,9 +9224,9 @@ if (o) { s.super_ = o; var TempCtor = function () {}; - (TempCtor.prototype = o.prototype), + ((TempCtor.prototype = o.prototype), (s.prototype = new TempCtor()), - (s.prototype.constructor = s); + (s.prototype.constructor = s)); } }); }, @@ -9222,15 +9242,15 @@ ? window.URL.createObjectURL(_) : window.webkitURL.createObjectURL(_), x = document.createElement('a'); - (x.style.display = 'none'), + ((x.style.display = 'none'), (x.href = w), x.setAttribute('download', o), void 0 === x.download && x.setAttribute('target', '_blank'), document.body.appendChild(x), x.click(), setTimeout(function () { - document.body.removeChild(x), window.URL.revokeObjectURL(w); - }, 200); + (document.body.removeChild(x), window.URL.revokeObjectURL(w)); + }, 200)); } }; }, @@ -9291,7 +9311,7 @@ function invokeFunc(o) { var i = u, w = _; - return (u = _ = void 0), (L = o), (x = s.apply(w, i)); + return ((u = _ = void 0), (L = o), (x = s.apply(w, i))); } function shouldInvoke(s) { var i = s - j; @@ -9309,7 +9329,7 @@ ); } function trailingEdge(s) { - return (C = void 0), z && u ? invokeFunc(s) : ((u = _ = void 0), x); + return ((C = void 0), z && u ? invokeFunc(s) : ((u = _ = void 0), x)); } function debounced() { var s = now(), @@ -9317,11 +9337,11 @@ if (((u = arguments), (_ = this), (j = s), i)) { if (void 0 === C) return (function leadingEdge(s) { - return (L = s), (C = setTimeout(timerExpired, o)), B ? invokeFunc(s) : x; + return ((L = s), (C = setTimeout(timerExpired, o)), B ? invokeFunc(s) : x); })(j); - if ($) return (C = setTimeout(timerExpired, o)), invokeFunc(j); + if ($) return ((C = setTimeout(timerExpired, o)), invokeFunc(j)); } - return void 0 === C && (C = setTimeout(timerExpired, o)), x; + return (void 0 === C && (C = setTimeout(timerExpired, o)), x); } return ( (o = toNumber(o) || 0), @@ -9330,7 +9350,7 @@ (w = ($ = 'maxWait' in i) ? V(toNumber(i.maxWait) || 0, o) : w), (z = 'trailing' in i ? !!i.trailing : z)), (debounced.cancel = function cancel() { - void 0 !== C && clearTimeout(C), (L = 0), (u = j = _ = C = void 0); + (void 0 !== C && clearTimeout(C), (L = 0), (u = j = _ = C = void 0)); }), (debounced.flush = function flush() { return void 0 === C ? x : trailingEdge(now()); @@ -9357,28 +9377,28 @@ this.set(u[0], u[1]); } } - (Hash.prototype.clear = u), + ((Hash.prototype.clear = u), (Hash.prototype.delete = _), (Hash.prototype.get = w), (Hash.prototype.has = x), (Hash.prototype.set = C), - (s.exports = Hash); + (s.exports = Hash)); }, 30980: (s, o, i) => { var u = i(39344), _ = i(94033); function LazyWrapper(s) { - (this.__wrapped__ = s), + ((this.__wrapped__ = s), (this.__actions__ = []), (this.__dir__ = 1), (this.__filtered__ = !1), (this.__iteratees__ = []), (this.__takeCount__ = 4294967295), - (this.__views__ = []); + (this.__views__ = [])); } - (LazyWrapper.prototype = u(_.prototype)), + ((LazyWrapper.prototype = u(_.prototype)), (LazyWrapper.prototype.constructor = LazyWrapper), - (s.exports = LazyWrapper); + (s.exports = LazyWrapper)); }, 80079: (s, o, i) => { var u = i(63702), @@ -9394,26 +9414,26 @@ this.set(u[0], u[1]); } } - (ListCache.prototype.clear = u), + ((ListCache.prototype.clear = u), (ListCache.prototype.delete = _), (ListCache.prototype.get = w), (ListCache.prototype.has = x), (ListCache.prototype.set = C), - (s.exports = ListCache); + (s.exports = ListCache)); }, 56017: (s, o, i) => { var u = i(39344), _ = i(94033); function LodashWrapper(s, o) { - (this.__wrapped__ = s), + ((this.__wrapped__ = s), (this.__actions__ = []), (this.__chain__ = !!o), (this.__index__ = 0), - (this.__values__ = void 0); + (this.__values__ = void 0)); } - (LodashWrapper.prototype = u(_.prototype)), + ((LodashWrapper.prototype = u(_.prototype)), (LodashWrapper.prototype.constructor = LodashWrapper), - (s.exports = LodashWrapper); + (s.exports = LodashWrapper)); }, 68223: (s, o, i) => { var u = i(56110)(i(9325), 'Map'); @@ -9433,12 +9453,12 @@ this.set(u[0], u[1]); } } - (MapCache.prototype.clear = u), + ((MapCache.prototype.clear = u), (MapCache.prototype.delete = _), (MapCache.prototype.get = w), (MapCache.prototype.has = x), (MapCache.prototype.set = C), - (s.exports = MapCache); + (s.exports = MapCache)); }, 32804: (s, o, i) => { var u = i(56110)(i(9325), 'Promise'); @@ -9457,9 +9477,9 @@ i = null == s ? 0 : s.length; for (this.__data__ = new u(); ++o < i; ) this.add(s[o]); } - (SetCache.prototype.add = SetCache.prototype.push = _), + ((SetCache.prototype.add = SetCache.prototype.push = _), (SetCache.prototype.has = w), - (s.exports = SetCache); + (s.exports = SetCache)); }, 37217: (s, o, i) => { var u = i(80079), @@ -9472,12 +9492,12 @@ var o = (this.__data__ = new u(s)); this.size = o.size; } - (Stack.prototype.clear = _), + ((Stack.prototype.clear = _), (Stack.prototype.delete = w), (Stack.prototype.get = x), (Stack.prototype.has = C), (Stack.prototype.set = j), - (s.exports = Stack); + (s.exports = Stack)); }, 51873: (s, o, i) => { var u = i(9325).Symbol; @@ -9664,7 +9684,7 @@ be = '[object Function]', _e = '[object Object]', we = {}; - (we[ye] = + ((we[ye] = we['[object Array]'] = we['[object ArrayBuffer]'] = we['[object DataView]'] = @@ -9713,7 +9733,7 @@ Pe || (Pe = new u()); var Ye = Pe.get(s); if (Ye) return Ye; - Pe.set(s, Te), + (Pe.set(s, Te), pe(s) ? s.forEach(function (u) { Te.add(baseClone(u, o, i, u, s, Pe)); @@ -9721,15 +9741,15 @@ : le(s) && s.forEach(function (u, _) { Te.set(_, baseClone(u, o, i, _, s, Pe)); - }); + })); var Xe = ze ? void 0 : ($e ? (qe ? U : V) : qe ? fe : de)(s); return ( _(Xe || s, function (u, _) { - Xe && (u = s[(_ = u)]), w(Te, _, baseClone(u, o, i, _, s, Pe)); + (Xe && (u = s[(_ = u)]), w(Te, _, baseClone(u, o, i, _, s, Pe))); }), Te ); - }); + })); }, 39344: (s, o, i) => { var u = i(23805), @@ -9741,7 +9761,7 @@ if (_) return _(s); object.prototype = s; var o = new object(); - return (object.prototype = void 0), o; + return ((object.prototype = void 0), o); }; })(); s.exports = w; @@ -9878,11 +9898,12 @@ fe = le == ce; if (fe && L(s)) { if (!L(o)) return !1; - (ie = !0), (pe = !1); + ((ie = !0), (pe = !1)); } if (fe && !pe) return ( - ee || (ee = new u()), ie || B(s) ? _(s, o, i, Y, Z, ee) : w(s, o, le, i, Y, Z, ee) + ee || (ee = new u()), + ie || B(s) ? _(s, o, i, Y, Z, ee) : w(s, o, le, i, Y, Z, ee) ); if (!(1 & i)) { var ye = pe && z.call(s, '__wrapped__'), @@ -9890,7 +9911,7 @@ if (ye || be) { var _e = ye ? s.value() : s, we = be ? o.value() : o; - return ee || (ee = new u()), Z(_e, we, i, Y, ee); + return (ee || (ee = new u()), Z(_e, we, i, Y, ee)); } } return !!fe && (ee || (ee = new u()), x(s, o, i, Y, Z, ee)); @@ -9968,7 +9989,7 @@ _ = i(30294), w = i(40346), x = {}; - (x['[object Float32Array]'] = + ((x['[object Float32Array]'] = x['[object Float64Array]'] = x['[object Int8Array]'] = x['[object Int16Array]'] = @@ -9996,7 +10017,7 @@ !1), (s.exports = function baseIsTypedArray(s) { return w(s) && _(s.length) && !!x[u(s)]; - }); + })); }, 15389: (s, o, i) => { var u = i(93663), @@ -10089,7 +10110,7 @@ if (($ || ($ = new u()), C(w))) x(s, o, j, i, baseMerge, B, $); else { var V = B ? B(L(s, j), w, j + '', s, o, $) : void 0; - void 0 === V && (V = w), _(s, j, V); + (void 0 === V && (V = w), _(s, j, V)); } }, j @@ -10124,7 +10145,7 @@ var _e = L(de), we = !_e && $(de), Se = !_e && !we && Y(de); - (ye = de), + ((ye = de), _e || we || Se ? L(pe) ? (ye = pe) @@ -10137,9 +10158,9 @@ : (ye = []) : z(de) || j(de) ? ((ye = pe), j(pe) ? (ye = ee(pe)) : (U(pe) && !V(pe)) || (ye = C(de))) - : (be = !1); + : (be = !1)); } - be && (ce.set(de, ye), ae(ye, de, ie, le, ce), ce.delete(de)), u(s, i, ye); + (be && (ce.set(de, ye), ae(ye, de, ie, le, ce), ce.delete(de)), u(s, i, ye)); } }; }, @@ -10199,7 +10220,7 @@ var Y = V[U]; void 0 === (z = j ? j(Y, U, V) : void 0) && (z = x(Y) ? Y : w(o[L + 1]) ? [] : {}); } - u(V, U, z), (V = V[U]); + (u(V, U, z), (V = V[U])); } return s; }; @@ -10209,7 +10230,7 @@ _ = i(48152), w = _ ? function (s, o) { - return _.set(s, o), s; + return (_.set(s, o), s); } : u; s.exports = w; @@ -10234,10 +10255,10 @@ s.exports = function baseSlice(s, o, i) { var u = -1, _ = s.length; - o < 0 && (o = -o > _ ? 0 : _ + o), + (o < 0 && (o = -o > _ ? 0 : _ + o), (i = i > _ ? _ : i) < 0 && (i += _), (_ = o > i ? 0 : (i - o) >>> 0), - (o >>>= 0); + (o >>>= 0)); for (var w = Array(_); ++u < _; ) w[u] = s[u + o]; return w; }; @@ -10295,7 +10316,7 @@ w = i(68969), x = i(77797); s.exports = function baseUnset(s, o) { - return (o = u(o, s)), null == (s = w(s, o)) || delete s[x(_(o))]; + return ((o = u(o, s)), null == (s = w(s, o)) || delete s[x(_(o))]); }; }, 51234: (s) => { @@ -10325,14 +10346,14 @@ var u = i(25160); s.exports = function castSlice(s, o, i) { var _ = s.length; - return (i = void 0 === i ? _ : i), !o && i >= _ ? s : u(s, o, i); + return ((i = void 0 === i ? _ : i), !o && i >= _ ? s : u(s, o, i)); }; }, 49653: (s, o, i) => { var u = i(37828); s.exports = function cloneArrayBuffer(s) { var o = new s.constructor(s.byteLength); - return new u(o).set(new u(s)), o; + return (new u(o).set(new u(s)), o); }; }, 93290: (s, o, i) => { @@ -10346,7 +10367,7 @@ if (o) return s.slice(); var i = s.length, u = C ? C(i) : new s.constructor(i); - return s.copy(u), u; + return (s.copy(u), u); }; }, 76169: (s, o, i) => { @@ -10360,7 +10381,7 @@ var o = /\w*$/; s.exports = function cloneRegExp(s) { var i = new s.constructor(s.source, o.exec(s)); - return (i.lastIndex = s.lastIndex), i; + return ((i.lastIndex = s.lastIndex), i); }; }, 93736: (s, o, i) => { @@ -10391,7 +10412,6 @@ $ = Array(L + B), V = !_; ++j < L; - ) $[j] = i[j]; for (; ++w < C; ) (V || w < x) && ($[u[w]] = s[w]); @@ -10413,7 +10433,6 @@ V = Array($ + B), U = !_; ++w < $; - ) V[w] = s[w]; for (var z = w; ++L < B; ) V[z + L] = i[L]; @@ -10438,7 +10457,7 @@ for (var C = -1, j = o.length; ++C < j; ) { var L = o[C], B = w ? w(i[L], s[L], L, i, s) : void 0; - void 0 === B && (B = s[L]), x ? _(i, L, B) : u(i, L, B); + (void 0 === B && (B = s[L]), x ? _(i, L, B) : u(i, L, B)); } return i; }; @@ -10481,7 +10500,6 @@ C && _(i[0], i[1], C) && ((x = w < 3 ? void 0 : x), (w = 1)), o = Object(o); ++u < w; - ) { var j = i[u]; j && s(o, j, u, x); @@ -10499,7 +10517,6 @@ for ( var w = i.length, x = o ? w : -1, C = Object(i); (o ? x-- : ++x < w) && !1 !== _(C[x], x, C); - ); return i; }; @@ -10615,10 +10632,10 @@ var C = Object(o); if (!_(o)) { var j = u(i, 3); - (o = w(o)), + ((o = w(o)), (i = function (s) { return j(C[s], s, C); - }); + })); } var L = s(o, i, x); return L > -1 ? C[j ? o[L] : L] : void 0; @@ -10685,7 +10702,6 @@ $ = Array(B + _), V = this && this !== w && this instanceof wrapper ? j : s; ++L < B; - ) $[L] = x[L]; for (; _--; ) $[L++] = arguments[++o]; @@ -10699,7 +10715,7 @@ w = i(70981); s.exports = function createRecurry(s, o, i, x, C, j, L, B, $, V) { var U = 8 & o; - (o |= U ? 32 : 64), 4 & (o &= ~(U ? 64 : 32)) || (o &= -4); + ((o |= U ? 32 : 64), 4 & (o &= ~(U ? 64 : 32)) || (o &= -4)); var z = [ s, o, @@ -10713,7 +10729,7 @@ V ], Y = i.apply(void 0, z); - return u(s) && _(Y, z), (Y.placeholder = x), w(Y, s, o); + return (u(s) && _(Y, z), (Y.placeholder = x), w(Y, s, o)); }; }, 66977: (s, o, i) => { @@ -10973,7 +10989,7 @@ _ = (function () { try { var s = u(Object, 'defineProperty'); - return s({}, '', {}), s; + return (s({}, '', {}), s); } catch (s) {} })(); s.exports = _; @@ -11016,7 +11032,7 @@ break; } } - return j.delete(s), j.delete(o), Y; + return (j.delete(s), j.delete(o), Y); }; }, 21986: (s, o, i) => { @@ -11032,7 +11048,7 @@ switch (i) { case '[object DataView]': if (s.byteLength != o.byteLength || s.byteOffset != o.byteOffset) return !1; - (s = s.buffer), (o = o.buffer); + ((s = s.buffer), (o = o.buffer)); case '[object ArrayBuffer]': return !(s.byteLength != o.byteLength || !$(new _(s), new _(o))); case '[object Boolean]': @@ -11051,9 +11067,9 @@ if ((U || (U = j), s.size != o.size && !z)) return !1; var Y = V.get(s); if (Y) return Y == o; - (u |= 2), V.set(s, o); + ((u |= 2), V.set(s, o)); var Z = x(U(s), U(o), u, L, $, V); - return V.delete(s), Z; + return (V.delete(s), Z); case '[object Symbol]': if (B) return B.call(s) == B.call(o); } @@ -11076,7 +11092,7 @@ z = C.get(o); if (U && z) return U == o && z == s; var Y = !0; - C.set(s, o), C.set(o, s); + (C.set(s, o), C.set(o, s)); for (var Z = j; ++$ < B; ) { var ee = s[(V = L[$])], ie = o[V]; @@ -11099,7 +11115,7 @@ ce instanceof ce) || (Y = !1); } - return C.delete(s), C.delete(o), Y; + return (C.delete(s), C.delete(o), Y); }; }, 38816: (s, o, i) => { @@ -11202,7 +11218,7 @@ var u = !0; } catch (s) {} var _ = x.call(s); - return u && (o ? (s[C] = i) : delete s[C]), _; + return (u && (o ? (s[C] = i) : delete s[C]), _); }; }, 4664: (s, o, i) => { @@ -11229,7 +11245,7 @@ x = i(63345), C = Object.getOwnPropertySymbols ? function (s) { - for (var o = []; s; ) u(o, w(s)), (s = _(s)); + for (var o = []; s; ) (u(o, w(s)), (s = _(s))); return o; } : x; @@ -11254,7 +11270,7 @@ ie = L(x), ae = L(C), le = j; - ((u && le(new u(new ArrayBuffer(1))) != z) || + (((u && le(new u(new ArrayBuffer(1))) != z) || (_ && le(new _()) != B) || (w && le(w.resolve()) != $) || (x && le(new x()) != V) || @@ -11278,7 +11294,7 @@ } return o; }), - (s.exports = le); + (s.exports = le)); }, 10392: (s) => { s.exports = function getValue(s, o) { @@ -11328,13 +11344,13 @@ 22032: (s, o, i) => { var u = i(81042); s.exports = function hashClear() { - (this.__data__ = u ? u(null) : {}), (this.size = 0); + ((this.__data__ = u ? u(null) : {}), (this.size = 0)); }; }, 63862: (s) => { s.exports = function hashDelete(s) { var o = this.has(s) && delete this.__data__[s]; - return (this.size -= o ? 1 : 0), o; + return ((this.size -= o ? 1 : 0), o); }; }, 66721: (s, o, i) => { @@ -11540,7 +11556,7 @@ }, 63702: (s) => { s.exports = function listCacheClear() { - (this.__data__ = []), (this.size = 0); + ((this.__data__ = []), (this.size = 0)); }; }, 70080: (s, o, i) => { @@ -11571,7 +11587,7 @@ s.exports = function listCacheSet(s, o) { var i = this.__data__, _ = u(i, s); - return _ < 0 ? (++this.size, i.push([s, o])) : (i[_][1] = o), this; + return (_ < 0 ? (++this.size, i.push([s, o])) : (i[_][1] = o), this); }; }, 63040: (s, o, i) => { @@ -11579,15 +11595,15 @@ _ = i(80079), w = i(68223); s.exports = function mapCacheClear() { - (this.size = 0), - (this.__data__ = { hash: new u(), map: new (w || _)(), string: new u() }); + ((this.size = 0), + (this.__data__ = { hash: new u(), map: new (w || _)(), string: new u() })); }; }, 17670: (s, o, i) => { var u = i(12651); s.exports = function mapCacheDelete(s) { var o = u(this, s).delete(s); - return (this.size -= o ? 1 : 0), o; + return ((this.size -= o ? 1 : 0), o); }; }, 90289: (s, o, i) => { @@ -11607,7 +11623,7 @@ s.exports = function mapCacheSet(s, o) { var i = u(this, s), _ = i.size; - return i.set(s, o), (this.size += i.size == _ ? 0 : 1), this; + return (i.set(s, o), (this.size += i.size == _ ? 0 : 1), this); }; }, 20317: (s) => { @@ -11633,7 +11649,7 @@ var u = i(50104); s.exports = function memoizeCapped(s) { var o = u(s, function (s) { - return 500 === i.size && i.clear(), s; + return (500 === i.size && i.clear(), s); }), i = o.cache; return o; @@ -11660,7 +11676,7 @@ var U = o[3]; if (U) { var z = s[3]; - (s[3] = z ? u(z, U, o[4]) : U), (s[4] = z ? w(s[3], x) : o[4]); + ((s[3] = z ? u(z, U, o[4]) : U), (s[4] = z ? w(s[3], x) : o[4])); } return ( (U = o[5]) && @@ -11732,7 +11748,7 @@ j[x] = w[o + x]; x = -1; for (var L = Array(o + 1); ++x < o; ) L[x] = w[x]; - return (L[o] = i(j)), u(s, this, L); + return ((L[o] = i(j)), u(s, this, L)); } ); }; @@ -11782,7 +11798,7 @@ }, 31380: (s) => { s.exports = function setCacheAdd(s) { - return this.__data__.set(s, '__lodash_hash_undefined__'), this; + return (this.__data__.set(s, '__lodash_hash_undefined__'), this); }; }, 51459: (s) => { @@ -11840,14 +11856,14 @@ 51420: (s, o, i) => { var u = i(80079); s.exports = function stackClear() { - (this.__data__ = new u()), (this.size = 0); + ((this.__data__ = new u()), (this.size = 0)); }; }, 90938: (s) => { s.exports = function stackDelete(s) { var o = this.__data__, i = o.delete(s); - return (this.size = o.size), i; + return ((this.size = o.size), i); }; }, 63605: (s) => { @@ -11868,10 +11884,10 @@ var i = this.__data__; if (i instanceof u) { var x = i.__data__; - if (!_ || x.length < 199) return x.push([s, o]), (this.size = ++i.size), this; + if (!_ || x.length < 199) return (x.push([s, o]), (this.size = ++i.size), this); i = this.__data__ = new w(x); } - return i.set(s, o), (this.size = i.size), this; + return (i.set(s, o), (this.size = i.size), this); }; }, 76959: (s) => { @@ -12044,7 +12060,7 @@ 84058: (s, o, i) => { var u = i(14792), _ = i(45539)(function (s, o, i) { - return (o = o.toLowerCase()), s + (i ? u(o) : o); + return ((o = o.toLowerCase()), s + (i ? u(o) : o)); }); s.exports = _; }, @@ -12072,9 +12088,9 @@ var u = i(66977); function curry(s, o, i) { var _ = u(s, 8, void 0, void 0, void 0, void 0, void 0, (o = i ? void 0 : o)); - return (_.placeholder = curry.placeholder), _; + return ((_.placeholder = curry.placeholder), _); } - (curry.placeholder = {}), (s.exports = curry); + ((curry.placeholder = {}), (s.exports = curry)); }, 38221: (s, o, i) => { var u = i(23805), @@ -12097,7 +12113,7 @@ function invokeFunc(o) { var i = j, u = L; - return (j = L = void 0), (z = o), ($ = s.apply(u, i)); + return ((j = L = void 0), (z = o), ($ = s.apply(u, i))); } function shouldInvoke(s) { var i = s - U; @@ -12115,7 +12131,7 @@ ); } function trailingEdge(s) { - return (V = void 0), ee && j ? invokeFunc(s) : ((j = L = void 0), $); + return ((V = void 0), ee && j ? invokeFunc(s) : ((j = L = void 0), $)); } function debounced() { var s = _(), @@ -12123,11 +12139,11 @@ if (((j = arguments), (L = this), (U = s), i)) { if (void 0 === V) return (function leadingEdge(s) { - return (z = s), (V = setTimeout(timerExpired, o)), Y ? invokeFunc(s) : $; + return ((z = s), (V = setTimeout(timerExpired, o)), Y ? invokeFunc(s) : $); })(U); - if (Z) return clearTimeout(V), (V = setTimeout(timerExpired, o)), invokeFunc(U); + if (Z) return (clearTimeout(V), (V = setTimeout(timerExpired, o)), invokeFunc(U)); } - return void 0 === V && (V = setTimeout(timerExpired, o)), $; + return (void 0 === V && (V = setTimeout(timerExpired, o)), $); } return ( (o = w(o) || 0), @@ -12136,7 +12152,7 @@ (B = (Z = 'maxWait' in i) ? x(w(i.maxWait) || 0, o) : B), (ee = 'trailing' in i ? !!i.trailing : ee)), (debounced.cancel = function cancel() { - void 0 !== V && clearTimeout(V), (z = 0), (j = U = L = V = void 0); + (void 0 !== V && clearTimeout(V), (z = 0), (j = U = L = V = void 0)); }), (debounced.flush = function flush() { return void 0 === V ? $ : trailingEdge(_()); @@ -12180,7 +12196,7 @@ var C = null == s ? 0 : s.length; if (!C) return -1; var j = null == i ? 0 : w(i); - return j < 0 && (j = x(C + j, 0)), u(s, _(o, 3), j); + return (j < 0 && (j = x(C + j, 0)), u(s, _(o, 3), j)); }; }, 35970: (s, o, i) => { @@ -12212,7 +12228,7 @@ if (i) { for (var u = Array(i); i--; ) u[i] = arguments[i]; var _ = (u[0] = o.apply(void 0, u)); - return s.apply(void 0, u), _; + return (s.apply(void 0, u), _); } }; } @@ -12357,7 +12373,9 @@ var x = _[o], C = _.slice(0, o); return ( - x && w.apply(C, x), o != u && w.apply(C, _.slice(o + 1)), s.apply(this, C) + x && w.apply(C, x), + o != u && w.apply(C, _.slice(o + 1)), + s.apply(this, C) ); }; })(o, x); @@ -12373,12 +12391,11 @@ for ( var i = -1, u = (o = Te(o)).length, _ = u - 1, w = pe(Object(s)), x = w; null != x && ++i < u; - ) { var C = o[i], j = x[C]; - null == j || _e(j) || be(j) || we(j) || (x[C] = pe(i == _ ? j : Object(j))), - (x = x[C]); + (null == j || _e(j) || be(j) || we(j) || (x[C] = pe(i == _ ? j : Object(j))), + (x = x[C])); } return w; } @@ -12399,7 +12416,7 @@ if (!i) return s(); for (var u = Array(i); i--; ) u[i] = arguments[i]; var _ = U ? 0 : i - 1; - return (u[_] = o(u[_])), s.apply(void 0, u); + return ((u[_] = o(u[_])), s.apply(void 0, u)); }; } function wrap(s, o, i) { @@ -12469,7 +12486,7 @@ var o = $e[s]; if ('function' == typeof o) { for (var i = ze.length; i--; ) if (ze[i][0] == s) return; - (o.convert = createConverter(s, o)), ze.push([s, o]); + ((o.convert = createConverter(s, o)), ze.push([s, o])); } }), fe(ze, function (s) { @@ -12489,7 +12506,7 @@ }; }, 16962: (s, o) => { - (o.aliasToReal = { + ((o.aliasToReal = { each: 'forEach', eachRight: 'forEachRight', entries: 'toPairs', @@ -12982,7 +12999,7 @@ zip: !0, zipObject: !0, zipObjectDeep: !0 - }); + })); }, 47934: (s, o, i) => { s.exports = { @@ -13017,7 +13034,7 @@ }, 77731: (s, o, i) => { var u = i(79920)('set', i(63560)); - (u.placeholder = i(2874)), (s.exports = u); + ((u.placeholder = i(2874)), (s.exports = u)); }, 58156: (s, o, i) => { var u = i(47422); @@ -13291,11 +13308,11 @@ _ = memoized.cache; if (_.has(u)) return _.get(u); var w = s.apply(this, i); - return (memoized.cache = _.set(u, w) || _), w; + return ((memoized.cache = _.set(u, w) || _), w); }; - return (memoized.cache = new (memoize.Cache || u)()), memoized; + return ((memoized.cache = new (memoize.Cache || u)()), memoized); } - (memoize.Cache = u), (s.exports = memoize); + ((memoize.Cache = u), (s.exports = memoize)); }, 55364: (s, o, i) => { var u = i(85250), @@ -13345,11 +13362,11 @@ var i = {}; if (null == s) return i; var L = !1; - (o = u(o, function (o) { - return (o = x(o, s)), L || (L = o.length > 1), o; + ((o = u(o, function (o) { + return ((o = x(o, s)), L || (L = o.length > 1), o); })), C(s, B(s), i), - L && (i = _(i, 7, j)); + L && (i = _(i, 7, j))); for (var $ = o.length; $--; ) w(i, o[$]); return i; }); @@ -13398,7 +13415,7 @@ C = i(36800); s.exports = function some(s, o, i) { var j = x(s) ? u : w; - return i && C(s, o, i) && (o = void 0), j(s, _(o, 3)); + return (i && C(s, o, i) && (o = void 0), j(s, _(o, 3))); }; }, 63345: (s) => { @@ -13497,7 +13514,8 @@ x = i(22225); s.exports = function words(s, o, i) { return ( - (s = w(s)), void 0 === (o = i ? void 0 : o) ? (_(s) ? x(s) : u(s)) : s.match(o) || [] + (s = w(s)), + void 0 === (o = i ? void 0 : o) ? (_(s) ? x(s) : u(s)) : s.match(o) || [] ); }; }, @@ -13516,9 +13534,9 @@ } return new _(s); } - (lodash.prototype = w.prototype), + ((lodash.prototype = w.prototype), (lodash.prototype.constructor = lodash), - (s.exports = lodash); + (s.exports = lodash)); }, 47248: (s, o, i) => { var u = i(16547), @@ -13531,7 +13549,7 @@ 'use strict'; var u = i(45981), _ = i(85587); - (o.highlight = highlight), + ((o.highlight = highlight), (o.highlightAuto = function highlightAuto(s, o) { var i, x, @@ -13544,14 +13562,14 @@ U = -1; null == $ && ($ = w); if ('string' != typeof s) throw _('Expected `string` for value, got `%s`', s); - (x = { relevance: 0, language: null, value: [] }), - (i = { relevance: 0, language: null, value: [] }); + ((x = { relevance: 0, language: null, value: [] }), + (i = { relevance: 0, language: null, value: [] })); for (; ++U < V; ) - (j = B[U]), + ((j = B[U]), u.getLanguage(j) && (((C = highlight(j, s, o)).language = j), C.relevance > x.relevance && (x = C), - C.relevance > i.relevance && ((x = i), (i = C))); + C.relevance > i.relevance && ((x = i), (i = C)))); x.language && (i.secondBest = x); return i; }), @@ -13572,13 +13590,13 @@ i, u = this.stack; if ('' === s) return; - (o = u[u.length - 1]), + ((o = u[u.length - 1]), (i = o.children[o.children.length - 1]) && 'text' === i.type ? (i.value += s) - : o.children.push({ type: 'text', value: s }); + : o.children.push({ type: 'text', value: s })); }), (Emitter.prototype.addKeyword = function addKeyword(s, o) { - this.openNode(o), this.addText(s), this.closeNode(); + (this.openNode(o), this.addText(s), this.closeNode()); }), (Emitter.prototype.addSublanguage = function addSublanguage(s, o) { var i = this.stack, @@ -13604,7 +13622,7 @@ properties: { className: [i] }, children: [] }; - u.children.push(_), o.push(_); + (u.children.push(_), o.push(_)); }), (Emitter.prototype.closeNode = function close() { this.stack.pop(); @@ -13613,7 +13631,7 @@ (Emitter.prototype.finalize = noop), (Emitter.prototype.toHTML = function toHtmlNoop() { return ''; - }); + })); var w = 'hljs-'; function highlight(s, o, i) { var x, @@ -13637,7 +13655,9 @@ }; } function Emitter(s) { - (this.options = s), (this.rootNode = { children: [] }), (this.stack = [this.rootNode]); + ((this.options = s), + (this.rootNode = { children: [] }), + (this.stack = [this.rootNode])); } function noop() {} }, @@ -13675,7 +13695,8 @@ } filter(s, o) { return ( - (s = coerceElementMatchingCallback(s)), new ArraySlice(this.elements.filter(s, o)) + (s = coerceElementMatchingCallback(s)), + new ArraySlice(this.elements.filter(s, o)) ); } reject(s, o) { @@ -13685,7 +13706,7 @@ ); } find(s, o) { - return (s = coerceElementMatchingCallback(s)), this.elements.find(s, o); + return ((s = coerceElementMatchingCallback(s)), this.elements.find(s, o)); } forEach(s, o) { this.elements.forEach(s, o); @@ -13703,7 +13724,7 @@ this.elements.unshift(this.refract(s)); } push(s) { - return this.elements.push(this.refract(s)), this; + return (this.elements.push(this.refract(s)), this); } add(s) { this.push(s); @@ -13725,16 +13746,16 @@ return this.elements[0]; } } - 'undefined' != typeof Symbol && + ('undefined' != typeof Symbol && (ArraySlice.prototype[Symbol.iterator] = function symbol() { return this.elements[Symbol.iterator](); }), - (s.exports = ArraySlice); + (s.exports = ArraySlice)); }, 55973: (s) => { class KeyValuePair { constructor(s, o) { - (this.key = s), (this.value = o); + ((this.key = s), (this.value = o)); } clone() { const s = new KeyValuePair(); @@ -13757,17 +13778,19 @@ L = i(86804); class Namespace { constructor(s) { - (this.elementMap = {}), + ((this.elementMap = {}), (this.elementDetection = []), (this.Element = L.Element), (this.KeyValuePair = L.KeyValuePair), (s && s.noDefault) || this.useDefault(), (this._attributeElementKeys = []), - (this._attributeElementArrayKeys = []); + (this._attributeElementArrayKeys = [])); } use(s) { return ( - s.namespace && s.namespace({ base: this }), s.load && s.load({ base: this }), this + s.namespace && s.namespace({ base: this }), + s.load && s.load({ base: this }), + this ); } useDefault() { @@ -13791,10 +13814,10 @@ ); } register(s, o) { - return (this._elements = void 0), (this.elementMap[s] = o), this; + return ((this._elements = void 0), (this.elementMap[s] = o), this); } unregister(s) { - return (this._elements = void 0), delete this.elementMap[s], this; + return ((this._elements = void 0), delete this.elementMap[s], this); } detect(s, o, i) { return ( @@ -13842,7 +13865,7 @@ return new j(this); } } - (j.prototype.Namespace = Namespace), (s.exports = Namespace); + ((j.prototype.Namespace = Namespace), (s.exports = Namespace)); }, 10866: (s, o, i) => { const u = i(6048), @@ -13897,7 +13920,7 @@ } return s; } - (u.prototype.ObjectElement = B), + ((u.prototype.ObjectElement = B), (u.prototype.RefElement = V), (u.prototype.MemberElement = L), (u.prototype.refract = refract), @@ -13917,13 +13940,13 @@ ArraySlice: U, ObjectSlice: z, KeyValuePair: Y - }); + })); }, 86303: (s, o, i) => { const u = i(10316); s.exports = class LinkElement extends u { constructor(s, o, i) { - super(s || [], o, i), (this.element = 'link'); + (super(s || [], o, i), (this.element = 'link')); } get relation() { return this.attributes.get('relation'); @@ -13943,7 +13966,7 @@ const u = i(10316); s.exports = class RefElement extends u { constructor(s, o, i) { - super(s || [], o, i), (this.element = 'ref'), this.path || (this.path = 'element'); + (super(s || [], o, i), (this.element = 'ref'), this.path || (this.path = 'element')); } get path() { return this.attributes.get('path'); @@ -13956,7 +13979,7 @@ 34035: (s, o, i) => { const u = i(3110), _ = i(86804); - (o.g$ = u), + ((o.g$ = u), (o.KeyValuePair = i(55973)), (o.G6 = _.ArraySlice), (o.ot = _.ObjectSlice), @@ -13972,7 +13995,7 @@ (o.Ft = _.LinkElement), (o.e = _.refract), i(85105), - i(75147); + i(75147)); }, 6233: (s, o, i) => { const u = i(6048), @@ -13980,7 +14003,7 @@ w = i(92340); class ArrayElement extends _ { constructor(s, o, i) { - super(s || [], o, i), (this.element = 'array'); + (super(s || [], o, i), (this.element = 'array')); } primitive() { return 'array'; @@ -13996,7 +14019,7 @@ return this.content[s]; } set(s, o) { - return (this.content[s] = this.refract(o)), this; + return ((this.content[s] = this.refract(o)), this); } remove(s) { const o = this.content.splice(s, 1); @@ -14050,7 +14073,7 @@ this.content.unshift(this.refract(s)); } push(s) { - return this.content.push(this.refract(s)), this; + return (this.content.push(this.refract(s)), this); } add(s) { this.push(s); @@ -14061,8 +14084,10 @@ _ = void 0 === i.results ? [] : i.results; return ( this.forEach((o, i, w) => { - u && void 0 !== o.findElements && o.findElements(s, { results: _, recursive: u }), - s(o, i, w) && _.push(o); + (u && + void 0 !== o.findElements && + o.findElements(s, { results: _, recursive: u }), + s(o, i, w) && _.push(o)); }), _ ); @@ -14125,7 +14150,7 @@ return this.getIndex(this.length - 1); } } - (ArrayElement.empty = function empty() { + ((ArrayElement.empty = function empty() { return new this(); }), (ArrayElement['fantasy-land/empty'] = ArrayElement.empty), @@ -14133,13 +14158,13 @@ (ArrayElement.prototype[Symbol.iterator] = function symbol() { return this.content[Symbol.iterator](); }), - (s.exports = ArrayElement); + (s.exports = ArrayElement)); }, 12242: (s, o, i) => { const u = i(10316); s.exports = class BooleanElement extends u { constructor(s, o, i) { - super(s, o, i), (this.element = 'boolean'); + (super(s, o, i), (this.element = 'boolean')); } primitive() { return 'boolean'; @@ -14152,14 +14177,14 @@ w = i(92340); class Element { constructor(s, o, i) { - o && (this.meta = o), i && (this.attributes = i), (this.content = s); + (o && (this.meta = o), i && (this.attributes = i), (this.content = s)); } freeze() { Object.isFrozen(this) || (this._meta && ((this.meta.parent = this), this.meta.freeze()), this._attributes && ((this.attributes.parent = this), this.attributes.freeze()), this.children.forEach((s) => { - (s.parent = this), s.freeze(); + ((s.parent = this), s.freeze()); }, this), this.content && Array.isArray(this.content) && Object.freeze(this.content), Object.freeze(this)); @@ -14197,7 +14222,7 @@ if ('' === this.id.toValue()) throw Error('Cannot create reference to an element that does not contain an ID'); const o = new this.RefElement(this.id.toValue()); - return s && (o.path = s), o; + return (s && (o.path = s), o); } findRecursive(...s) { if (arguments.length > 1 && !this.isFrozen) @@ -14237,7 +14262,7 @@ ); } set(s) { - return (this.content = s), this; + return ((this.content = s), this); } equals(s) { return u(this.toValue(), s); @@ -14246,7 +14271,7 @@ if (!this.meta.hasKey(s)) { if (this.isFrozen) { const s = this.refract(o); - return s.freeze(), s; + return (s.freeze(), s); } this.meta.set(s, o); } @@ -14286,7 +14311,7 @@ if (!this._meta) { if (this.isFrozen) { const s = new this.ObjectElement(); - return s.freeze(), s; + return (s.freeze(), s); } this._meta = new this.ObjectElement(); } @@ -14299,7 +14324,7 @@ if (!this._attributes) { if (this.isFrozen) { const s = new this.ObjectElement(); - return s.freeze(), s; + return (s.freeze(), s); } this._attributes = new this.ObjectElement(); } @@ -14346,14 +14371,14 @@ get parents() { let { parent: s } = this; const o = new w(); - for (; s; ) o.push(s), (s = s.parent); + for (; s; ) (o.push(s), (s = s.parent)); return o; } get children() { if (Array.isArray(this.content)) return new w(this.content); if (this.content instanceof _) { const s = new w([this.content.key]); - return this.content.value && s.push(this.content.value), s; + return (this.content.value && s.push(this.content.value), s); } return this.content instanceof Element ? new w([this.content]) : new w(); } @@ -14361,10 +14386,10 @@ const s = new w(); return ( this.children.forEach((o) => { - s.push(o), + (s.push(o), o.recursiveChildren.forEach((o) => { s.push(o); - }); + })); }), s ); @@ -14377,7 +14402,7 @@ _ = i(10316); s.exports = class MemberElement extends _ { constructor(s, o, i, _) { - super(new u(), i, _), (this.element = 'member'), (this.key = s), (this.value = o); + (super(new u(), i, _), (this.element = 'member'), (this.key = s), (this.value = o)); } get key() { return this.content.key; @@ -14397,7 +14422,7 @@ const u = i(10316); s.exports = class NullElement extends u { constructor(s, o, i) { - super(s || null, o, i), (this.element = 'null'); + (super(s || null, o, i), (this.element = 'null')); } primitive() { return 'null'; @@ -14411,7 +14436,7 @@ const u = i(10316); s.exports = class NumberElement extends u { constructor(s, o, i) { - super(s, o, i), (this.element = 'number'); + (super(s, o, i), (this.element = 'number')); } primitive() { return 'number'; @@ -14426,7 +14451,7 @@ C = i(10866); s.exports = class ObjectElement extends w { constructor(s, o, i) { - super(s || [], o, i), (this.element = 'object'); + (super(s || [], o, i), (this.element = 'object')); } primitive() { return 'object'; @@ -14465,7 +14490,7 @@ ); const i = s, u = this.getMember(i); - return u ? (u.value = o) : this.content.push(new x(i, o)), this; + return (u ? (u.value = o) : this.content.push(new x(i, o)), this); } keys() { return this.content.map((s) => s.key.toValue()); @@ -14507,7 +14532,7 @@ const u = i(10316); s.exports = class StringElement extends u { constructor(s, o, i) { - super(s, o, i), (this.element = 'string'); + (super(s, o, i), (this.element = 'string')); } primitive() { return 'string'; @@ -14533,22 +14558,22 @@ o && (i.attributes = o); } else if (s._attributes && s._attributes.length > 0) { let { attributes: u } = s; - u.get('metadata') && + (u.get('metadata') && ((u = u.clone()), u.set('meta', u.get('metadata')), u.remove('metadata')), 'member' === s.element && o && ((u = u.clone()), u.remove('variable')), - u.length > 0 && (i.attributes = this.serialiseObject(u)); + u.length > 0 && (i.attributes = this.serialiseObject(u))); } if (u) i.content = this.enumSerialiseContent(s, i); else if (this[`${s.element}SerialiseContent`]) i.content = this[`${s.element}SerialiseContent`](s, i); else if (void 0 !== s.content) { let u; - o && s.content.key + (o && s.content.key ? ((u = s.content.clone()), u.key.attributes.set('variable', o), (u = this.serialiseContent(u))) : (u = this.serialiseContent(s.content)), - this.shouldSerialiseContent(s, u) && (i.content = u); + this.shouldSerialiseContent(s, u) && (i.content = u)); } else this.shouldSerialiseContent(s, s.content) && s instanceof this.namespace.elements.Array && @@ -14566,7 +14591,7 @@ ); } refSerialiseContent(s, o) { - return delete o.attributes, { href: s.toValue(), path: s.path.toValue() }; + return (delete o.attributes, { href: s.toValue(), path: s.path.toValue() }); } sourceMapSerialiseContent(s) { return s.toValue(); @@ -14604,12 +14629,12 @@ if (o && o.length > 0) return o.content.map((s) => { const o = s.clone(); - return o.attributes.remove('typeAttributes'), this.serialise(o); + return (o.attributes.remove('typeAttributes'), this.serialise(o)); }); } if (s.content) { const o = s.content.clone(); - return o.attributes.remove('typeAttributes'), [this.serialise(o)]; + return (o.attributes.remove('typeAttributes'), [this.serialise(o)]); } return []; } @@ -14622,30 +14647,30 @@ return new this.namespace.elements.Array(s.map(this.deserialise, this)); const o = this.namespace.getElementClass(s.element), i = new o(); - i.element !== s.element && (i.element = s.element), + (i.element !== s.element && (i.element = s.element), s.meta && this.deserialiseObject(s.meta, i.meta), - s.attributes && this.deserialiseObject(s.attributes, i.attributes); + s.attributes && this.deserialiseObject(s.attributes, i.attributes)); const u = this.deserialiseContent(s.content); if (((void 0 === u && null !== i.content) || (i.content = u), 'enum' === i.element)) { i.content && i.attributes.set('enumerations', i.content); let s = i.attributes.get('samples'); if ((i.attributes.remove('samples'), s)) { const u = s; - (s = new this.namespace.elements.Array()), + ((s = new this.namespace.elements.Array()), u.forEach((u) => { u.forEach((u) => { const _ = new o(u); - (_.element = i.element), s.push(_); + ((_.element = i.element), s.push(_)); }); - }); + })); const _ = s.shift(); - (i.content = _ ? _.content : void 0), i.attributes.set('samples', s); + ((i.content = _ ? _.content : void 0), i.attributes.set('samples', s)); } else i.content = void 0; let u = i.attributes.get('default'); if (u && u.length > 0) { u = u.get(0); const s = new o(u); - (s.element = i.element), i.attributes.set('default', s); + ((s.element = i.element), i.attributes.set('default', s)); } } else if ('dataStructure' === i.element && Array.isArray(i.content)) [i.content] = i.content; @@ -14665,7 +14690,7 @@ if (s instanceof this.namespace.elements.Element) return this.serialise(s); if (s instanceof this.namespace.KeyValuePair) { const o = { key: this.serialise(s.key) }; - return s.value && (o.value = this.serialise(s.value)), o; + return (s.value && (o.value = this.serialise(s.value)), o); } return s && s.map ? s.map(this.serialise, this) : s; } @@ -14674,7 +14699,7 @@ if (s.element) return this.deserialise(s); if (s.key) { const o = new this.namespace.KeyValuePair(this.deserialise(s.key)); - return s.value && (o.value = this.deserialise(s.value)), o; + return (s.value && (o.value = this.deserialise(s.value)), o); } if (s.map) return s.map(this.deserialise, this); } @@ -14737,28 +14762,28 @@ if (!(s instanceof this.namespace.elements.Element)) throw new TypeError(`Given element \`${s}\` is not an Element instance`); const o = { element: s.element }; - s._meta && s._meta.length > 0 && (o.meta = this.serialiseObject(s.meta)), + (s._meta && s._meta.length > 0 && (o.meta = this.serialiseObject(s.meta)), s._attributes && s._attributes.length > 0 && - (o.attributes = this.serialiseObject(s.attributes)); + (o.attributes = this.serialiseObject(s.attributes))); const i = this.serialiseContent(s.content); - return void 0 !== i && (o.content = i), o; + return (void 0 !== i && (o.content = i), o); } deserialise(s) { if (!s.element) throw new Error('Given value is not an object containing an element name'); const o = new (this.namespace.getElementClass(s.element))(); - o.element !== s.element && (o.element = s.element), + (o.element !== s.element && (o.element = s.element), s.meta && this.deserialiseObject(s.meta, o.meta), - s.attributes && this.deserialiseObject(s.attributes, o.attributes); + s.attributes && this.deserialiseObject(s.attributes, o.attributes)); const i = this.deserialiseContent(s.content); - return (void 0 === i && null !== o.content) || (o.content = i), o; + return ((void 0 === i && null !== o.content) || (o.content = i), o); } serialiseContent(s) { if (s instanceof this.namespace.elements.Element) return this.serialise(s); if (s instanceof this.namespace.KeyValuePair) { const o = { key: this.serialise(s.key) }; - return s.value && (o.value = this.serialise(s.value)), o; + return (s.value && (o.value = this.serialise(s.value)), o); } if (s && s.map) { if (0 === s.length) return; @@ -14771,7 +14796,7 @@ if (s.element) return this.deserialise(s); if (s.key) { const o = new this.namespace.KeyValuePair(this.deserialise(s.key)); - return s.value && (o.value = this.deserialise(s.value)), o; + return (s.value && (o.value = this.deserialise(s.value)), o); } if (s.map) return s.map(this.deserialise, this); } @@ -14807,7 +14832,7 @@ function runTimeout(s) { if (o === setTimeout) return setTimeout(s, 0); if ((o === defaultSetTimout || !o) && setTimeout) - return (o = setTimeout), setTimeout(s, 0); + return ((o = setTimeout), setTimeout(s, 0)); try { return o(s, 0); } catch (i) { @@ -14843,14 +14868,14 @@ x = !0; for (var o = w.length; o; ) { for (_ = w, w = []; ++C < o; ) _ && _[C].run(); - (C = -1), (o = w.length); + ((C = -1), (o = w.length)); } - (_ = null), + ((_ = null), (x = !1), (function runClearTimeout(s) { if (i === clearTimeout) return clearTimeout(s); if ((i === defaultClearTimeout || !i) && clearTimeout) - return (i = clearTimeout), clearTimeout(s); + return ((i = clearTimeout), clearTimeout(s)); try { return i(s); } catch (o) { @@ -14860,18 +14885,18 @@ return i.call(this, s); } } - })(s); + })(s)); } } function Item(s, o) { - (this.fun = s), (this.array = o); + ((this.fun = s), (this.array = o)); } function noop() {} - (u.nextTick = function (s) { + ((u.nextTick = function (s) { var o = new Array(arguments.length - 1); if (arguments.length > 1) for (var i = 1; i < arguments.length; i++) o[i - 1] = arguments[i]; - w.push(new Item(s, o)), 1 !== w.length || x || runTimeout(drainQueue); + (w.push(new Item(s, o)), 1 !== w.length || x || runTimeout(drainQueue)); }), (Item.prototype.run = function () { this.fun.apply(null, this.array); @@ -14905,14 +14930,14 @@ }), (u.umask = function () { return 0; - }); + })); }, 2694: (s, o, i) => { 'use strict'; var u = i(6925); function emptyFunction() {} function emptyFunctionWithReset() {} - (emptyFunctionWithReset.resetWarningCache = emptyFunction), + ((emptyFunctionWithReset.resetWarningCache = emptyFunction), (s.exports = function () { function shim(s, o, i, _, w, x) { if (x !== u) { @@ -14949,8 +14974,8 @@ checkPropTypes: emptyFunctionWithReset, resetWarningCache: emptyFunction }; - return (s.PropTypes = s), s; - }); + return ((s.PropTypes = s), s); + })); }, 5556: (s, o, i) => { s.exports = i(2694)(); @@ -14976,7 +15001,7 @@ return null; } } - (o.stringify = function querystringify(s, o) { + ((o.stringify = function querystringify(s, o) { o = o || ''; var u, _, @@ -15001,7 +15026,7 @@ null === _ || null === w || _ in u || (u[_] = w); } return u; - }); + })); }, 41859: (s, o, i) => { const u = i(27096), @@ -15010,23 +15035,23 @@ s.exports = class RandExp { constructor(s, o) { if ((this._setDefaults(s), s instanceof RegExp)) - (this.ignoreCase = s.ignoreCase), (this.multiline = s.multiline), (s = s.source); + ((this.ignoreCase = s.ignoreCase), (this.multiline = s.multiline), (s = s.source)); else { if ('string' != typeof s) throw new Error('Expected a regexp or string'); - (this.ignoreCase = o && -1 !== o.indexOf('i')), - (this.multiline = o && -1 !== o.indexOf('m')); + ((this.ignoreCase = o && -1 !== o.indexOf('i')), + (this.multiline = o && -1 !== o.indexOf('m'))); } this.tokens = u(s); } _setDefaults(s) { - (this.max = + ((this.max = null != s.max ? s.max : null != RandExp.prototype.max ? RandExp.prototype.max : 100), (this.defaultRange = s.defaultRange ? s.defaultRange : this.defaultRange.clone()), - s.randInt && (this.randInt = s.randInt); + s.randInt && (this.randInt = s.randInt)); } gen() { return this._gen(this.tokens, []); @@ -15046,7 +15071,7 @@ x++ ) u += this._gen(i[x], o); - return s.remember && (o[s.groupNumber] = u), u; + return (s.remember && (o[s.groupNumber] = u), u); case w.POSITION: return ''; case w.SET: @@ -15172,7 +15197,7 @@ _typeof(s) ); } - Object.defineProperty(o, '__esModule', { value: !0 }), (o.CopyToClipboard = void 0); + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.CopyToClipboard = void 0)); var u = _interopRequireDefault(i(96540)), _ = _interopRequireDefault(i(17965)), w = ['text', 'onCopy', 'options', 'children']; @@ -15183,11 +15208,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -15216,25 +15241,25 @@ u, _ = {}, w = Object.keys(s); - for (u = 0; u < w.length; u++) (i = w[u]), o.indexOf(i) >= 0 || (_[i] = s[i]); + for (u = 0; u < w.length; u++) ((i = w[u]), o.indexOf(i) >= 0 || (_[i] = s[i])); return _; })(s, o); if (Object.getOwnPropertySymbols) { var w = Object.getOwnPropertySymbols(s); for (u = 0; u < w.length; u++) - (i = w[u]), + ((i = w[u]), o.indexOf(i) >= 0 || - (Object.prototype.propertyIsEnumerable.call(s, i) && (_[i] = s[i])); + (Object.prototype.propertyIsEnumerable.call(s, i) && (_[i] = s[i]))); } return _; } function _defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, u.key, u); + Object.defineProperty(s, u.key, u)); } } function _setPrototypeOf(s, o) { @@ -15242,7 +15267,7 @@ (_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(s, o) { - return (s.__proto__ = o), s; + return ((s.__proto__ = o), s); }), _setPrototypeOf(s, o) ); @@ -15254,7 +15279,8 @@ if ('function' == typeof Proxy) return !0; try { return ( - Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})), !0 + Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})), + !0 ); } catch (s) { return !1; @@ -15307,11 +15333,11 @@ !(function _inherits(s, o) { if ('function' != typeof o && null !== o) throw new TypeError('Super expression must either be null or a function'); - (s.prototype = Object.create(o && o.prototype, { + ((s.prototype = Object.create(o && o.prototype, { constructor: { value: s, writable: !0, configurable: !0 } })), Object.defineProperty(s, 'prototype', { writable: !1 }), - o && _setPrototypeOf(s, o); + o && _setPrototypeOf(s, o)); })(CopyToClipboard, s); var o = _createSuper(CopyToClipboard); function CopyToClipboard() { @@ -15333,8 +15359,8 @@ j = i.options, L = u.default.Children.only(C), B = (0, _.default)(w, j); - x && x(w, B), - L && L.props && 'function' == typeof L.props.onClick && L.props.onClick(o); + (x && x(w, B), + L && L.props && 'function' == typeof L.props.onClick && L.props.onClick(o)); } ), s @@ -15366,13 +15392,13 @@ CopyToClipboard ); })(u.default.PureComponent); - (o.CopyToClipboard = x), - _defineProperty(x, 'defaultProps', { onCopy: void 0, options: void 0 }); + ((o.CopyToClipboard = x), + _defineProperty(x, 'defaultProps', { onCopy: void 0, options: void 0 })); }, 59399: (s, o, i) => { 'use strict'; var u = i(25264).CopyToClipboard; - (u.CopyToClipboard = u), (s.exports = u); + ((u.CopyToClipboard = u), (s.exports = u)); }, 81214: (s, o, i) => { 'use strict'; @@ -15394,7 +15420,7 @@ _typeof(s) ); } - Object.defineProperty(o, '__esModule', { value: !0 }), (o.DebounceInput = void 0); + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.DebounceInput = void 0)); var u = _interopRequireDefault(i(96540)), _ = _interopRequireDefault(i(20181)), w = [ @@ -15422,15 +15448,15 @@ u, _ = {}, w = Object.keys(s); - for (u = 0; u < w.length; u++) (i = w[u]), o.indexOf(i) >= 0 || (_[i] = s[i]); + for (u = 0; u < w.length; u++) ((i = w[u]), o.indexOf(i) >= 0 || (_[i] = s[i])); return _; })(s, o); if (Object.getOwnPropertySymbols) { var w = Object.getOwnPropertySymbols(s); for (u = 0; u < w.length; u++) - (i = w[u]), + ((i = w[u]), o.indexOf(i) >= 0 || - (Object.prototype.propertyIsEnumerable.call(s, i) && (_[i] = s[i])); + (Object.prototype.propertyIsEnumerable.call(s, i) && (_[i] = s[i]))); } return _; } @@ -15438,11 +15464,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -15464,10 +15490,10 @@ function _defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, u.key, u); + Object.defineProperty(s, u.key, u)); } } function _setPrototypeOf(s, o) { @@ -15475,7 +15501,7 @@ (_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(s, o) { - return (s.__proto__ = o), s; + return ((s.__proto__ = o), s); }), _setPrototypeOf(s, o) ); @@ -15487,7 +15513,8 @@ if ('function' == typeof Proxy) return !0; try { return ( - Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})), !0 + Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})), + !0 ); } catch (s) { return !1; @@ -15540,16 +15567,16 @@ !(function _inherits(s, o) { if ('function' != typeof o && null !== o) throw new TypeError('Super expression must either be null or a function'); - (s.prototype = Object.create(o && o.prototype, { + ((s.prototype = Object.create(o && o.prototype, { constructor: { value: s, writable: !0, configurable: !0 } })), Object.defineProperty(s, 'prototype', { writable: !1 }), - o && _setPrototypeOf(s, o); + o && _setPrototypeOf(s, o)); })(DebounceInput, s); var o = _createSuper(DebounceInput); function DebounceInput(s) { var i; - !(function _classCallCheck(s, o) { + (!(function _classCallCheck(s, o) { if (!(s instanceof o)) throw new TypeError('Cannot call a class as a function'); })(this, DebounceInput), _defineProperty( @@ -15598,17 +15625,17 @@ else if (0 === s) i.notify = i.doNotify; else { var o = (0, _.default)(function (s) { - (i.isDebouncing = !1), i.doNotify(s); + ((i.isDebouncing = !1), i.doNotify(s)); }, s); - (i.notify = function (s) { - (i.isDebouncing = !0), o(s); + ((i.notify = function (s) { + ((i.isDebouncing = !0), o(s)); }), (i.flush = function () { return o.flush(); }), (i.cancel = function () { - (i.isDebouncing = !1), o.cancel(); - }); + ((i.isDebouncing = !1), o.cancel()); + })); } }), _defineProperty(_assertThisInitialized(i), 'doNotify', function () { @@ -15632,9 +15659,9 @@ } }), (i.isDebouncing = !1), - (i.state = { value: void 0 === s.value || null === s.value ? '' : s.value }); + (i.state = { value: void 0 === s.value || null === s.value ? '' : s.value })); var u = i.props.debounceTimeout; - return i.createNotifier(u), i; + return (i.createNotifier(u), i); } return ( (function _createClass(s, o, i) { @@ -15655,8 +15682,8 @@ _ = s.debounceTimeout, w = s.value, x = this.state.value; - void 0 !== i && w !== i && x !== i && this.setState({ value: i }), - u !== _ && this.createNotifier(u); + (void 0 !== i && w !== i && x !== i && this.setState({ value: i }), + u !== _ && this.createNotifier(u)); } } }, @@ -15681,8 +15708,8 @@ B = i.inputRef, $ = _objectWithoutProperties(i, w), V = this.state.value; - (s = x ? { onKeyDown: this.onKeyDown } : j ? { onKeyDown: j } : {}), - (o = C ? { onBlur: this.onBlur } : L ? { onBlur: L } : {}); + ((s = x ? { onKeyDown: this.onKeyDown } : j ? { onKeyDown: j } : {}), + (o = C ? { onBlur: this.onBlur } : L ? { onBlur: L } : {})); var U = B ? { ref: B } : {}; return u.default.createElement( _, @@ -15705,7 +15732,7 @@ DebounceInput ); })(u.default.PureComponent); - (o.DebounceInput = x), + ((o.DebounceInput = x), _defineProperty(x, 'defaultProps', { element: 'input', type: 'text', @@ -15717,12 +15744,12 @@ forceNotifyByEnter: !0, forceNotifyOnBlur: !0, inputRef: void 0 - }); + })); }, 24677: (s, o, i) => { 'use strict'; var u = i(81214).DebounceInput; - (u.DebounceInput = u), (s.exports = u); + ((u.DebounceInput = u), (s.exports = u)); }, 22551: (s, o, i) => { 'use strict'; @@ -15746,7 +15773,7 @@ var w = new Set(), x = {}; function fa(s, o) { - ha(s, o), ha(s + 'Capture', o); + (ha(s, o), ha(s + 'Capture', o)); } function ha(s, o) { for (x[s] = o, s = 0; s < o.length; s++) w.add(o[s]); @@ -15762,17 +15789,17 @@ B = {}, $ = {}; function v(s, o, i, u, _, w, x) { - (this.acceptsBooleans = 2 === o || 3 === o || 4 === o), + ((this.acceptsBooleans = 2 === o || 3 === o || 4 === o), (this.attributeName = u), (this.attributeNamespace = _), (this.mustUseProperty = i), (this.propertyName = s), (this.type = o), (this.sanitizeURL = w), - (this.removeEmptyString = x); + (this.removeEmptyString = x)); } var V = {}; - 'children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style' + ('children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style' .split(' ') .forEach(function (s) { V[s] = new v(s, 0, !1, s, null, !1, !1); @@ -15810,7 +15837,7 @@ }), ['rowSpan', 'start'].forEach(function (s) { V[s] = new v(s, 5, !1, s.toLowerCase(), null, !1, !1); - }); + })); var U = /[\-:]([a-z])/g; function sa(s) { return s[1].toUpperCase(); @@ -15875,7 +15902,7 @@ : ((i = 3 === (_ = _.type) || (4 === _ && !0 === i) ? '' : '' + i), u ? s.setAttributeNS(u, o, i) : s.setAttribute(o, i)))); } - 'accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height' + ('accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height' .split(' ') .forEach(function (s) { var o = s.replace(U, sa); @@ -15905,7 +15932,7 @@ )), ['src', 'href', 'action', 'formAction'].forEach(function (s) { V[s] = new v(s, 1, !1, s.toLowerCase(), null, !0, !0); - }); + })); var z = u.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, Y = Symbol.for('react.element'), Z = Symbol.for('react.portal'), @@ -15919,11 +15946,11 @@ fe = Symbol.for('react.suspense_list'), ye = Symbol.for('react.memo'), be = Symbol.for('react.lazy'); - Symbol.for('react.scope'), Symbol.for('react.debug_trace_mode'); + (Symbol.for('react.scope'), Symbol.for('react.debug_trace_mode')); var _e = Symbol.for('react.offscreen'); - Symbol.for('react.legacy_hidden'), + (Symbol.for('react.legacy_hidden'), Symbol.for('react.cache'), - Symbol.for('react.tracing_marker'); + Symbol.for('react.tracing_marker')); var we = Symbol.iterator; function Ka(s) { return null === s || 'object' != typeof s @@ -15993,7 +16020,6 @@ x = _.length - 1, C = w.length - 1; 1 <= x && 0 <= C && _[x] !== w[C]; - ) C--; for (; 1 <= x && 0 <= C; x--, C--) @@ -16014,7 +16040,7 @@ } } } finally { - (Pe = !1), (Error.prepareStackTrace = i); + ((Pe = !1), (Error.prepareStackTrace = i)); } return (s = s ? s.displayName || s.name : '') ? Ma(s) : ''; } @@ -16077,7 +16103,7 @@ case ye: return null !== (o = s.displayName || null) ? o : Qa(s.type) || 'Memo'; case be: - (o = s._payload), (s = s._init); + ((o = s._payload), (s = s._init)); try { return Qa(s(o)); } catch (s) {} @@ -16176,7 +16202,7 @@ return _.call(this); }, set: function (s) { - (u = '' + s), w.call(this, s); + ((u = '' + s), w.call(this, s)); } }), Object.defineProperty(s, o, { enumerable: i.enumerable }), @@ -16188,7 +16214,7 @@ u = '' + s; }, stopTracking: function () { - (s._valueTracker = null), delete s[o]; + ((s._valueTracker = null), delete s[o]); } } ); @@ -16227,13 +16253,13 @@ function Za(s, o) { var i = null == o.defaultValue ? '' : o.defaultValue, u = null != o.checked ? o.checked : o.defaultChecked; - (i = Sa(null != o.value ? o.value : i)), + ((i = Sa(null != o.value ? o.value : i)), (s._wrapperState = { initialChecked: u, initialValue: i, controlled: 'checkbox' === o.type || 'radio' === o.type ? null != o.checked : null != o.value - }); + })); } function ab(s, o) { null != (o = o.checked) && ta(s, 'checked', o, !1); @@ -16247,25 +16273,25 @@ ? ((0 === i && '' === s.value) || s.value != i) && (s.value = '' + i) : s.value !== '' + i && (s.value = '' + i); else if ('submit' === u || 'reset' === u) return void s.removeAttribute('value'); - o.hasOwnProperty('value') + (o.hasOwnProperty('value') ? cb(s, o.type, i) : o.hasOwnProperty('defaultValue') && cb(s, o.type, Sa(o.defaultValue)), null == o.checked && null != o.defaultChecked && - (s.defaultChecked = !!o.defaultChecked); + (s.defaultChecked = !!o.defaultChecked)); } function db(s, o, i) { if (o.hasOwnProperty('value') || o.hasOwnProperty('defaultValue')) { var u = o.type; if (!(('submit' !== u && 'reset' !== u) || (void 0 !== o.value && null !== o.value))) return; - (o = '' + s._wrapperState.initialValue), + ((o = '' + s._wrapperState.initialValue), i || o === s.value || (s.value = o), - (s.defaultValue = o); + (s.defaultValue = o)); } - '' !== (i = s.name) && (s.name = ''), + ('' !== (i = s.name) && (s.name = ''), (s.defaultChecked = !!s._wrapperState.initialChecked), - '' !== i && (s.name = i); + '' !== i && (s.name = i)); } function cb(s, o, i) { ('number' === o && Xa(s.ownerDocument) === s) || @@ -16279,13 +16305,13 @@ o = {}; for (var _ = 0; _ < i.length; _++) o['$' + i[_]] = !0; for (i = 0; i < s.length; i++) - (_ = o.hasOwnProperty('$' + s[i].value)), + ((_ = o.hasOwnProperty('$' + s[i].value)), s[i].selected !== _ && (s[i].selected = _), - _ && u && (s[i].defaultSelected = !0); + _ && u && (s[i].defaultSelected = !0)); } else { for (i = '' + Sa(i), o = null, _ = 0; _ < s.length; _++) { if (s[_].value === i) - return (s[_].selected = !0), void (u && (s[_].defaultSelected = !0)); + return ((s[_].selected = !0), void (u && (s[_].defaultSelected = !0))); null !== o || s[_].disabled || (o = s[_]); } null !== o && (o.selected = !0); @@ -16310,17 +16336,17 @@ } o = i; } - null == o && (o = ''), (i = o); + (null == o && (o = ''), (i = o)); } s._wrapperState = { initialValue: Sa(i) }; } function ib(s, o) { var i = Sa(o.value), u = Sa(o.defaultValue); - null != i && + (null != i && ((i = '' + i) !== s.value && (s.value = i), null == o.defaultValue && s.defaultValue !== i && (s.defaultValue = i)), - null != u && (s.defaultValue = '' + u); + null != u && (s.defaultValue = '' + u)); } function jb(s) { var o = s.textContent; @@ -16355,7 +16381,6 @@ '' + o.valueOf().toString() + '', o = Re.firstChild; s.firstChild; - ) s.removeChild(s.firstChild); for (; o.firstChild; ) s.appendChild(o.firstChild); @@ -16433,12 +16458,12 @@ if (o.hasOwnProperty(i)) { var u = 0 === i.indexOf('--'), _ = rb(i, o[i], u); - 'float' === i && (i = 'cssFloat'), u ? s.setProperty(i, _) : (s[i] = _); + ('float' === i && (i = 'cssFloat'), u ? s.setProperty(i, _) : (s[i] = _)); } } Object.keys(ze).forEach(function (s) { We.forEach(function (o) { - (o = o + s.charAt(0).toUpperCase() + s.substring(1)), (ze[o] = ze[s]); + ((o = o + s.charAt(0).toUpperCase() + s.substring(1)), (ze[o] = ze[s])); }); }); var He = xe( @@ -16531,7 +16556,7 @@ try { return Gb(s, o, i); } finally { - (tt = !1), (null !== Qe || null !== et) && (Hb(), Fb()); + ((tt = !1), (null !== Qe || null !== et) && (Hb(), Fb())); } } function Kb(s, o) { @@ -16552,14 +16577,14 @@ case 'onMouseUp': case 'onMouseUpCapture': case 'onMouseEnter': - (u = !u.disabled) || + ((u = !u.disabled) || (u = !( 'button' === (s = s.type) || 'input' === s || 'select' === s || 'textarea' === s )), - (s = !u); + (s = !u)); break e; default: s = !1; @@ -16572,13 +16597,13 @@ if (C) try { var nt = {}; - Object.defineProperty(nt, 'passive', { + (Object.defineProperty(nt, 'passive', { get: function () { rt = !0; } }), window.addEventListener('test', nt, nt), - window.removeEventListener('test', nt, nt); + window.removeEventListener('test', nt, nt)); } catch (qe) { rt = !1; } @@ -16596,11 +16621,11 @@ at = null, lt = { onError: function (s) { - (st = !0), (ot = s); + ((st = !0), (ot = s)); } }; function Tb(s, o, i, u, _, w, x, C, j) { - (st = !1), (ot = null), Nb.apply(lt, arguments); + ((st = !1), (ot = null), Nb.apply(lt, arguments)); } function Vb(s) { var o = s, @@ -16609,7 +16634,7 @@ else { s = o; do { - !!(4098 & (o = s).flags) && (i = o.return), (s = o.return); + (!!(4098 & (o = s).flags) && (i = o.return), (s = o.return)); } while (s); } return 3 === o.tag ? i : null; @@ -16646,21 +16671,21 @@ } if (_.child === w.child) { for (w = _.child; w; ) { - if (w === i) return Xb(_), s; - if (w === u) return Xb(_), o; + if (w === i) return (Xb(_), s); + if (w === u) return (Xb(_), o); w = w.sibling; } throw Error(p(188)); } - if (i.return !== u.return) (i = _), (u = w); + if (i.return !== u.return) ((i = _), (u = w)); else { for (var x = !1, C = _.child; C; ) { if (C === i) { - (x = !0), (i = _), (u = w); + ((x = !0), (i = _), (u = w)); break; } if (C === u) { - (x = !0), (u = _), (i = w); + ((x = !0), (u = _), (i = w)); break; } C = C.sibling; @@ -16668,11 +16693,11 @@ if (!x) { for (C = w.child; C; ) { if (C === i) { - (x = !0), (i = w), (u = _); + ((x = !0), (i = w), (u = _)); break; } if (C === u) { - (x = !0), (u = w), (i = _); + ((x = !0), (u = w), (i = _)); break; } C = C.sibling; @@ -16713,7 +16738,7 @@ var St = Math.clz32 ? Math.clz32 : function nc(s) { - return (s >>>= 0), 0 === s ? 32 : (31 - ((xt(s) / kt) | 0)) | 0; + return ((s >>>= 0), 0 === s ? 32 : (31 - ((xt(s) / kt) | 0)) | 0); }, xt = Math.log, kt = Math.LN2; @@ -16789,7 +16814,7 @@ return o; if ((4 & u && (u |= 16 & i), 0 !== (o = s.entangledLanes))) for (s = s.entanglements, o &= u; 0 < o; ) - (_ = 1 << (i = 31 - St(o))), (u |= s[i]), (o &= ~_); + ((_ = 1 << (i = 31 - St(o))), (u |= s[i]), (o &= ~_)); return u; } function vc(s, o) { @@ -16827,23 +16852,23 @@ } function yc() { var s = Ct; - return !(4194240 & (Ct <<= 1)) && (Ct = 64), s; + return (!(4194240 & (Ct <<= 1)) && (Ct = 64), s); } function zc(s) { for (var o = [], i = 0; 31 > i; i++) o.push(s); return o; } function Ac(s, o, i) { - (s.pendingLanes |= o), + ((s.pendingLanes |= o), 536870912 !== o && ((s.suspendedLanes = 0), (s.pingedLanes = 0)), - ((s = s.eventTimes)[(o = 31 - St(o))] = i); + ((s = s.eventTimes)[(o = 31 - St(o))] = i)); } function Cc(s, o) { var i = (s.entangledLanes |= o); for (s = s.entanglements; i; ) { var u = 31 - St(i), _ = 1 << u; - (_ & o) | (s[u] & o) && (s[u] |= o), (i &= ~_); + ((_ & o) | (s[u] & o) && (s[u] |= o), (i &= ~_)); } } var At = 0; @@ -16928,9 +16953,9 @@ if (null !== s.blockedOn) return !1; for (var o = s.targetContainers; 0 < o.length; ) { var i = Yc(s.domEventName, s.eventSystemFlags, o[0], s.nativeEvent); - if (null !== i) return null !== (o = Cb(i)) && It(o), (s.blockedOn = i), !1; + if (null !== i) return (null !== (o = Cb(i)) && It(o), (s.blockedOn = i), !1); var u = new (i = s.nativeEvent).constructor(i.type, i); - (Ye = u), i.target.dispatchEvent(u), (Ye = null), o.shift(); + ((Ye = u), i.target.dispatchEvent(u), (Ye = null), o.shift()); } return !0; } @@ -16938,12 +16963,12 @@ Xc(s) && i.delete(o); } function $c() { - (Nt = !1), + ((Nt = !1), null !== Dt && Xc(Dt) && (Dt = null), null !== Lt && Xc(Lt) && (Lt = null), null !== Bt && Xc(Bt) && (Bt = null), Ft.forEach(Zc), - qt.forEach(Zc); + qt.forEach(Zc)); } function ad(s, o) { s.blockedOn === o && @@ -16973,7 +16998,7 @@ ) (i = $t[o]).blockedOn === s && (i.blockedOn = null); for (; 0 < $t.length && null === (o = $t[0]).blockedOn; ) - Vc(o), null === o.blockedOn && $t.shift(); + (Vc(o), null === o.blockedOn && $t.shift()); } var Ut = z.ReactCurrentBatchConfig, zt = !0; @@ -16982,9 +17007,9 @@ w = Ut.transition; Ut.transition = null; try { - (At = 1), fd(s, o, i, u); + ((At = 1), fd(s, o, i, u)); } finally { - (At = _), (Ut.transition = w); + ((At = _), (Ut.transition = w)); } } function gd(s, o, i, u) { @@ -16992,29 +17017,33 @@ w = Ut.transition; Ut.transition = null; try { - (At = 4), fd(s, o, i, u); + ((At = 4), fd(s, o, i, u)); } finally { - (At = _), (Ut.transition = w); + ((At = _), (Ut.transition = w)); } } function fd(s, o, i, u) { if (zt) { var _ = Yc(s, o, i, u); - if (null === _) hd(s, o, u, Wt, i), Sc(s, u); + if (null === _) (hd(s, o, u, Wt, i), Sc(s, u)); else if ( (function Uc(s, o, i, u, _) { switch (o) { case 'focusin': - return (Dt = Tc(Dt, s, o, i, u, _)), !0; + return ((Dt = Tc(Dt, s, o, i, u, _)), !0); case 'dragenter': - return (Lt = Tc(Lt, s, o, i, u, _)), !0; + return ((Lt = Tc(Lt, s, o, i, u, _)), !0); case 'mouseover': - return (Bt = Tc(Bt, s, o, i, u, _)), !0; + return ((Bt = Tc(Bt, s, o, i, u, _)), !0); case 'pointerover': var w = _.pointerId; - return Ft.set(w, Tc(Ft.get(w) || null, s, o, i, u, _)), !0; + return (Ft.set(w, Tc(Ft.get(w) || null, s, o, i, u, _)), !0); case 'gotpointercapture': - return (w = _.pointerId), qt.set(w, Tc(qt.get(w) || null, s, o, i, u, _)), !0; + return ( + (w = _.pointerId), + qt.set(w, Tc(qt.get(w) || null, s, o, i, u, _)), + !0 + ); } return !1; })(_, s, o, i, u) @@ -17047,7 +17076,7 @@ return 3 === o.tag ? o.stateNode.containerInfo : null; s = null; } else o !== s && (s = null); - return (Wt = s), null; + return ((Wt = s), null); } function jd(s) { switch (s) { @@ -17468,10 +17497,10 @@ return 'input' === o ? !!Ir[s.type] : 'textarea' === o; } function ne(s, o, i, u) { - Eb(u), + (Eb(u), 0 < (o = oe(o, 'onChange')).length && ((i = new Qt('onChange', 'change', null, i, u)), - s.push({ event: i, listeners: o })); + s.push({ event: i, listeners: o }))); } var Pr = null, Mr = null; @@ -17491,7 +17520,7 @@ var Rr = 'oninput' in document; if (!Rr) { var Dr = document.createElement('div'); - Dr.setAttribute('oninput', 'return;'), (Rr = 'function' == typeof Dr.oninput); + (Dr.setAttribute('oninput', 'return;'), (Rr = 'function' == typeof Dr.oninput)); } Nr = Rr; } else Nr = !1; @@ -17503,7 +17532,7 @@ function Be(s) { if ('value' === s.propertyName && te(Mr)) { var o = []; - ne(o, Mr, s, xb(s)), Jb(re, o); + (ne(o, Mr, s, xb(s)), Jb(re, o)); } } function Ce(s, o, i) { @@ -17609,16 +17638,16 @@ if (o !== i && i && i.ownerDocument && Le(i.ownerDocument.documentElement, i)) { if (null !== u && Ne(i)) if (((o = u.start), void 0 === (s = u.end) && (s = o), 'selectionStart' in i)) - (i.selectionStart = o), (i.selectionEnd = Math.min(s, i.value.length)); + ((i.selectionStart = o), (i.selectionEnd = Math.min(s, i.value.length))); else if ( (s = ((o = i.ownerDocument || document) && o.defaultView) || window).getSelection ) { s = s.getSelection(); var _ = i.textContent.length, w = Math.min(u.start, _); - (u = void 0 === u.end ? w : Math.min(u.end, _)), + ((u = void 0 === u.end ? w : Math.min(u.end, _)), !s.extend && w > u && ((_ = u), (u = w), (w = _)), - (_ = Ke(i, w)); + (_ = Ke(i, w))); var x = Ke(i, u); _ && x && @@ -17636,7 +17665,7 @@ for (o = [], s = i; (s = s.parentNode); ) 1 === s.nodeType && o.push({ element: s, left: s.scrollLeft, top: s.scrollTop }); for ('function' == typeof i.focus && i.focus(), i = 0; i < o.length; i++) - ((s = o[i]).element.scrollLeft = s.left), (s.element.scrollTop = s.top); + (((s = o[i]).element.scrollLeft = s.left), (s.element.scrollTop = s.top)); } } var Br = C && 'documentMode' in document && 11 >= document.documentMode, @@ -17709,13 +17738,13 @@ ' ' ); function ff(s, o) { - Yr.set(s, o), fa(o, [s]); + (Yr.set(s, o), fa(o, [s])); } for (var Zr = 0; Zr < Xr.length; Zr++) { var Qr = Xr[Zr]; ff(Qr.toLowerCase(), 'on' + (Qr[0].toUpperCase() + Qr.slice(1))); } - ff(Kr, 'onAnimationEnd'), + (ff(Kr, 'onAnimationEnd'), ff(Hr, 'onAnimationIteration'), ff(Jr, 'onAnimationStart'), ff('dblclick', 'onDoubleClick'), @@ -17748,7 +17777,7 @@ fa( 'onCompositionUpdate', 'compositionupdate focusout keydown keypress keyup mousedown'.split(' ') - ); + )); var en = 'abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting'.split( ' ' @@ -17756,15 +17785,15 @@ tn = new Set('cancel close invalid load scroll toggle'.split(' ').concat(en)); function nf(s, o, i) { var u = s.type || 'unknown-event'; - (s.currentTarget = i), + ((s.currentTarget = i), (function Ub(s, o, i, u, _, w, x, C, j) { if ((Tb.apply(this, arguments), st)) { if (!st) throw Error(p(198)); var L = ot; - (st = !1), (ot = null), it || ((it = !0), (at = L)); + ((st = !1), (ot = null), it || ((it = !0), (at = L))); } })(u, o, void 0, s), - (s.currentTarget = null); + (s.currentTarget = null)); } function se(s, o) { o = !!(4 & o); @@ -17780,7 +17809,7 @@ j = C.instance, L = C.currentTarget; if (((C = C.listener), j !== w && _.isPropagationStopped())) break e; - nf(_, C, L), (w = j); + (nf(_, C, L), (w = j)); } else for (x = 0; x < u.length; x++) { @@ -17791,7 +17820,7 @@ j !== w && _.isPropagationStopped()) ) break e; - nf(_, C, L), (w = j); + (nf(_, C, L), (w = j)); } } } @@ -17805,15 +17834,15 @@ } function qf(s, o, i) { var u = 0; - o && (u |= 4), pf(i, s, u, o); + (o && (u |= 4), pf(i, s, u, o)); } var rn = '_reactListening' + Math.random().toString(36).slice(2); function sf(s) { if (!s[rn]) { - (s[rn] = !0), + ((s[rn] = !0), w.forEach(function (o) { 'selectionchange' !== o && (tn.has(o) || qf(o, !1, s), qf(o, !0, s)); - }); + })); var o = 9 === s.nodeType ? s : s.ownerDocument; null === o || o[rn] || ((o[rn] = !0), qf('selectionchange', !1, o)); } @@ -17829,7 +17858,7 @@ default: _ = fd; } - (i = _.bind(null, o, i, s)), + ((i = _.bind(null, o, i, s)), (_ = void 0), !rt || ('touchstart' !== o && 'touchmove' !== o && 'wheel' !== o) || (_ = !0), u @@ -17838,7 +17867,7 @@ : s.addEventListener(o, i, !0) : void 0 !== _ ? s.addEventListener(o, i, { passive: _ }) - : s.addEventListener(o, i, !1); + : s.addEventListener(o, i, !1)); } function hd(s, o, i, u, _) { var w = u; @@ -17888,10 +17917,10 @@ j = gr; break; case 'focusin': - (L = 'focus'), (j = ir); + ((L = 'focus'), (j = ir)); break; case 'focusout': - (L = 'blur'), (j = ir); + ((L = 'blur'), (j = ir)); break; case 'beforeblur': case 'afterblur': @@ -18017,16 +18046,17 @@ e: { for (V = L, z = 0, U = B = j; U; U = vf(U)) z++; for (U = 0, Y = V; Y; Y = vf(Y)) U++; - for (; 0 < z - U; ) (B = vf(B)), z--; - for (; 0 < U - z; ) (V = vf(V)), U--; + for (; 0 < z - U; ) ((B = vf(B)), z--); + for (; 0 < U - z; ) ((V = vf(V)), U--); for (; z--; ) { if (B === V || (null !== V && B === V.alternate)) break e; - (B = vf(B)), (V = vf(V)); + ((B = vf(B)), (V = vf(V))); } B = null; } else B = null; - null !== j && wf(x, C, j, B, !1), null !== L && null !== $ && wf(x, $, L, B, !0); + (null !== j && wf(x, C, j, B, !1), + null !== L && null !== $ && wf(x, $, L, B, !0)); } if ( 'select' === @@ -18069,7 +18099,7 @@ case 'contextmenu': case 'mouseup': case 'dragend': - (Vr = !1), Ue(x, i, _); + ((Vr = !1), Ue(x, i, _)); break; case 'selectionchange': if (Br) break; @@ -18097,7 +18127,7 @@ jr ? ge(s, i) && (ae = 'onCompositionEnd') : 'keydown' === s && 229 === i.keyCode && (ae = 'onCompositionStart'); - ae && + (ae && (Cr && 'ko' !== i.locale && (jr || 'onCompositionStart' !== ae @@ -18142,7 +18172,7 @@ 0 < (u = oe(u, 'onBeforeInput')).length && ((_ = new ur('onBeforeInput', 'beforeinput', null, i, _)), x.push({ event: _, listeners: u }), - (_.data = ie)); + (_.data = ie))); } se(x, o); }); @@ -18154,12 +18184,12 @@ for (var i = o + 'Capture', u = []; null !== s; ) { var _ = s, w = _.stateNode; - 5 === _.tag && + (5 === _.tag && null !== w && ((_ = w), null != (w = Kb(s, i)) && u.unshift(tf(s, w, _)), null != (w = Kb(s, o)) && u.push(tf(s, w, _))), - (s = s.return); + (s = s.return)); } return u; } @@ -18176,13 +18206,13 @@ j = C.alternate, L = C.stateNode; if (null !== j && j === u) break; - 5 === C.tag && + (5 === C.tag && null !== L && ((C = L), _ ? null != (j = Kb(i, w)) && x.unshift(tf(i, j, C)) : _ || (null != (j = Kb(i, w)) && x.push(tf(i, j, C)))), - (i = i.return); + (i = i.return)); } 0 !== x.length && s.push({ event: o, listeners: x }); } @@ -18231,7 +18261,7 @@ var _ = i.nextSibling; if ((s.removeChild(i), _ && 8 === _.nodeType)) if ('/$' === (i = _.data)) { - if (0 === u) return s.removeChild(_), void bd(o); + if (0 === u) return (s.removeChild(_), void bd(o)); u--; } else ('$' !== i && '$?' !== i && '$!' !== i) || u++; i = _; @@ -18308,7 +18338,7 @@ 0 > _n || ((s.current = bn[_n]), (bn[_n] = null), _n--); } function G(s, o) { - _n++, (bn[_n] = s.current), (s.current = o); + (_n++, (bn[_n] = s.current), (s.current = o)); } var En = {}, wn = Uf(En), @@ -18334,11 +18364,11 @@ return null != (s = s.childContextTypes); } function $f() { - E(Sn), E(wn); + (E(Sn), E(wn)); } function ag(s, o, i) { if (wn.current !== En) throw Error(p(168)); - G(wn, o), G(Sn, i); + (G(wn, o), G(Sn, i)); } function bg(s, o, i) { var u = s.stateNode; @@ -18359,14 +18389,14 @@ function dg(s, o, i) { var u = s.stateNode; if (!u) throw Error(p(169)); - i + (i ? ((s = bg(s, o, xn)), (u.__reactInternalMemoizedMergedChildContext = s), E(Sn), E(wn), G(wn, s)) : E(Sn), - G(Sn, i); + G(Sn, i)); } var kn = null, Cn = !1, @@ -18387,11 +18417,11 @@ u = u(!0); } while (null !== u); } - (kn = null), (Cn = !1); + ((kn = null), (Cn = !1)); } catch (o) { throw (null !== kn && (kn = kn.slice(s + 1)), ct(gt, jg), o); } finally { - (At = o), (On = !1); + ((At = o), (On = !1)); } } return null; @@ -18406,36 +18436,36 @@ Rn = 1, Dn = ''; function tg(s, o) { - (An[jn++] = Pn), (An[jn++] = In), (In = s), (Pn = o); + ((An[jn++] = Pn), (An[jn++] = In), (In = s), (Pn = o)); } function ug(s, o, i) { - (Mn[Tn++] = Rn), (Mn[Tn++] = Dn), (Mn[Tn++] = Nn), (Nn = s); + ((Mn[Tn++] = Rn), (Mn[Tn++] = Dn), (Mn[Tn++] = Nn), (Nn = s)); var u = Rn; s = Dn; var _ = 32 - St(u) - 1; - (u &= ~(1 << _)), (i += 1); + ((u &= ~(1 << _)), (i += 1)); var w = 32 - St(o) + _; if (30 < w) { var x = _ - (_ % 5); - (w = (u & ((1 << x) - 1)).toString(32)), + ((w = (u & ((1 << x) - 1)).toString(32)), (u >>= x), (_ -= x), (Rn = (1 << (32 - St(o) + _)) | (i << _) | u), - (Dn = w + s); - } else (Rn = (1 << w) | (i << _) | u), (Dn = s); + (Dn = w + s)); + } else ((Rn = (1 << w) | (i << _) | u), (Dn = s)); } function vg(s) { null !== s.return && (tg(s, 1), ug(s, 1, 0)); } function wg(s) { - for (; s === In; ) (In = An[--jn]), (An[jn] = null), (Pn = An[--jn]), (An[jn] = null); + for (; s === In; ) ((In = An[--jn]), (An[jn] = null), (Pn = An[--jn]), (An[jn] = null)); for (; s === Nn; ) - (Nn = Mn[--Tn]), + ((Nn = Mn[--Tn]), (Mn[Tn] = null), (Dn = Mn[--Tn]), (Mn[Tn] = null), (Rn = Mn[--Tn]), - (Mn[Tn] = null); + (Mn[Tn] = null)); } var Ln = null, Bn = null, @@ -18443,10 +18473,10 @@ qn = null; function Ag(s, o) { var i = Bg(5, null, null, 0); - (i.elementType = 'DELETED'), + ((i.elementType = 'DELETED'), (i.stateNode = o), (i.return = s), - null === (o = s.deletions) ? ((s.deletions = [i]), (s.flags |= 16)) : o.push(i); + null === (o = s.deletions) ? ((s.deletions = [i]), (s.flags |= 16)) : o.push(i)); } function Cg(s, o) { switch (s.tag) { @@ -18498,7 +18528,7 @@ } } else { if (Dg(s)) throw Error(p(418)); - (s.flags = (-4097 & s.flags) | 2), (Fn = !1), (Ln = s); + ((s.flags = (-4097 & s.flags) | 2), (Fn = !1), (Ln = s)); } } } @@ -18509,7 +18539,7 @@ } function Gg(s) { if (s !== Ln) return !1; - if (!Fn) return Fg(s), (Fn = !0), !1; + if (!Fn) return (Fg(s), (Fn = !0), !1); var o; if ( ((o = 3 !== s.tag) && @@ -18518,7 +18548,7 @@ o && (o = Bn)) ) { if (Dg(s)) throw (Hg(), Error(p(418))); - for (; o; ) Ag(s, o), (o = Lf(o.nextSibling)); + for (; o; ) (Ag(s, o), (o = Lf(o.nextSibling))); } if ((Fg(s), 13 === s.tag)) { if (!(s = null !== (s = s.memoizedState) ? s.dehydrated : null)) throw Error(p(317)); @@ -18545,7 +18575,7 @@ for (var s = Bn; s; ) s = Lf(s.nextSibling); } function Ig() { - (Bn = Ln = null), (Fn = !1); + ((Bn = Ln = null), (Fn = !1)); } function Jg(s) { null === qn ? (qn = [s]) : qn.push(s); @@ -18580,7 +18610,7 @@ } function Mg(s, o) { throw ( - ((s = Object.prototype.toString.call(o)), + (s = Object.prototype.toString.call(o)), Error( p( 31, @@ -18588,7 +18618,7 @@ ? 'object with keys {' + Object.keys(o).join(', ') + '}' : s ) - )) + ) ); } function Ng(s) { @@ -18603,16 +18633,16 @@ } function c(o, i) { if (!s) return null; - for (; null !== i; ) b(o, i), (i = i.sibling); + for (; null !== i; ) (b(o, i), (i = i.sibling)); return null; } function d(s, o) { for (s = new Map(); null !== o; ) - null !== o.key ? s.set(o.key, o) : s.set(o.index, o), (o = o.sibling); + (null !== o.key ? s.set(o.key, o) : s.set(o.index, o), (o = o.sibling)); return s; } function e(s, o) { - return ((s = Pg(s, o)).index = 0), (s.sibling = null), s; + return (((s = Pg(s, o)).index = 0), (s.sibling = null), s); } function f(o, i, u) { return ( @@ -18627,7 +18657,7 @@ ); } function g(o) { - return s && null === o.alternate && (o.flags |= 2), o; + return (s && null === o.alternate && (o.flags |= 2), o); } function h(s, o, i, u) { return null === o || 6 !== o.tag @@ -18661,7 +18691,7 @@ } function q(s, o, i) { if (('string' == typeof o && '' !== o) || 'number' == typeof o) - return ((o = Qg('' + o, s.mode, i)).return = s), o; + return (((o = Qg('' + o, s.mode, i)).return = s), o); if ('object' == typeof o && null !== o) { switch (o.$$typeof) { case Y: @@ -18671,11 +18701,11 @@ i ); case Z: - return ((o = Sg(o, s.mode, i)).return = s), o; + return (((o = Sg(o, s.mode, i)).return = s), o); case be: return q(s, (0, o._init)(o._payload), i); } - if (Te(o) || Ka(o)) return ((o = Tg(o, s.mode, i, null)).return = s), o; + if (Te(o) || Ka(o)) return (((o = Tg(o, s.mode, i, null)).return = s), o); Mg(s, o); } return null; @@ -18727,18 +18757,18 @@ null === C && (C = L); break; } - s && C && null === B.alternate && b(o, C), + (s && C && null === B.alternate && b(o, C), (i = f(B, i, j)), null === x ? (w = B) : (x.sibling = B), (x = B), - (C = L); + (C = L)); } - if (j === u.length) return c(o, C), Fn && tg(o, j), w; + if (j === u.length) return (c(o, C), Fn && tg(o, j), w); if (null === C) { for (; j < u.length; j++) null !== (C = q(o, u[j], _)) && ((i = f(C, i, j)), null === x ? (w = C) : (x.sibling = C), (x = C)); - return Fn && tg(o, j), w; + return (Fn && tg(o, j), w); } for (C = d(o, C); j < u.length; j++) null !== (L = y(C, o, j, u[j], _)) && @@ -18770,18 +18800,18 @@ null === C && (C = L); break; } - s && C && null === $.alternate && b(o, C), + (s && C && null === $.alternate && b(o, C), (i = f($, i, j)), null === x ? (w = $) : (x.sibling = $), (x = $), - (C = L); + (C = L)); } - if (B.done) return c(o, C), Fn && tg(o, j), w; + if (B.done) return (c(o, C), Fn && tg(o, j), w); if (null === C) { for (; !B.done; j++, B = u.next()) null !== (B = q(o, B.value, _)) && ((i = f(B, i, j)), null === x ? (w = B) : (x.sibling = B), (x = B)); - return Fn && tg(o, j), w; + return (Fn && tg(o, j), w); } for (C = d(o, C); !B.done; j++, B = u.next()) null !== (B = y(C, o, j, B.value, _)) && @@ -18814,7 +18844,7 @@ if (w.key === _) { if ((_ = i.type) === ee) { if (7 === w.tag) { - c(s, w.sibling), ((o = e(w, i.props.children)).return = s), (s = o); + (c(s, w.sibling), ((o = e(w, i.props.children)).return = s), (s = o)); break e; } } else if ( @@ -18824,16 +18854,16 @@ _.$$typeof === be && Ng(_) === w.type) ) { - c(s, w.sibling), + (c(s, w.sibling), ((o = e(w, i.props)).ref = Lg(s, w, i)), (o.return = s), - (s = o); + (s = o)); break e; } c(s, w); break; } - b(s, w), (w = w.sibling); + (b(s, w), (w = w.sibling)); } i.type === ee ? (((o = Tg(i.props.children, s.mode, u, i.key)).return = s), (s = o)) @@ -18851,15 +18881,15 @@ o.stateNode.containerInfo === i.containerInfo && o.stateNode.implementation === i.implementation ) { - c(s, o.sibling), ((o = e(o, i.children || [])).return = s), (s = o); + (c(s, o.sibling), ((o = e(o, i.children || [])).return = s), (s = o)); break e; } c(s, o); break; } - b(s, o), (o = o.sibling); + (b(s, o), (o = o.sibling)); } - ((o = Sg(i, s.mode, u)).return = s), (s = o); + (((o = Sg(i, s.mode, u)).return = s), (s = o)); } return g(s); case be: @@ -18889,7 +18919,7 @@ } function ah(s) { var o = zn.current; - E(zn), (s._currentValue = o); + (E(zn), (s._currentValue = o)); } function bh(s, o, i) { for (; null !== s; ) { @@ -18905,18 +18935,18 @@ } } function ch(s, o) { - (Wn = s), + ((Wn = s), (Hn = Kn = null), null !== (s = s.dependencies) && null !== s.firstContext && - (!!(s.lanes & o) && (_s = !0), (s.firstContext = null)); + (!!(s.lanes & o) && (_s = !0), (s.firstContext = null))); } function eh(s) { var o = s._currentValue; if (Hn !== s) if (((s = { context: s, memoizedValue: o, next: null }), null === Kn)) { if (null === Wn) throw Error(p(308)); - (Kn = s), (Wn.dependencies = { lanes: 0, firstContext: s }); + ((Kn = s), (Wn.dependencies = { lanes: 0, firstContext: s })); } else Kn = Kn.next = s; return o; } @@ -18936,10 +18966,10 @@ s.lanes |= o; var i = s.alternate; for (null !== i && (i.lanes |= o), i = s, s = s.return; null !== s; ) - (s.childLanes |= o), + ((s.childLanes |= o), null !== (i = s.alternate) && (i.childLanes |= o), (i = s), - (s = s.return); + (s = s.return)); return 3 === i.tag ? i.stateNode : null; } var Gn = !1; @@ -18953,7 +18983,7 @@ }; } function lh(s, o) { - (s = s.updateQueue), + ((s = s.updateQueue), o.updateQueue === s && (o.updateQueue = { baseState: s.baseState, @@ -18961,7 +18991,7 @@ lastBaseUpdate: s.lastBaseUpdate, shared: s.shared, effects: s.effects - }); + })); } function mh(s, o) { return { eventTime: s, lane: o, tag: 0, payload: null, callback: null, next: null }; @@ -18988,7 +19018,7 @@ function oh(s, o, i) { if (null !== (o = o.updateQueue) && ((o = o.shared), 4194240 & i)) { var u = o.lanes; - (i |= u &= s.pendingLanes), (o.lanes = i), Cc(s, i); + ((i |= u &= s.pendingLanes), (o.lanes = i), Cc(s, i)); } } function ph(s, o) { @@ -19007,7 +19037,7 @@ callback: i.callback, next: null }; - null === w ? (_ = w = x) : (w = w.next = x), (i = i.next); + (null === w ? (_ = w = x) : (w = w.next = x), (i = i.next)); } while (null !== i); null === w ? (_ = w = o) : (w = w.next = o); } else _ = w = o; @@ -19022,8 +19052,8 @@ void (s.updateQueue = i) ); } - null === (s = i.lastBaseUpdate) ? (i.firstBaseUpdate = o) : (s.next = o), - (i.lastBaseUpdate = o); + (null === (s = i.lastBaseUpdate) ? (i.firstBaseUpdate = o) : (s.next = o), + (i.lastBaseUpdate = o)); } function qh(s, o, i, u) { var _ = s.updateQueue; @@ -19035,7 +19065,7 @@ _.shared.pending = null; var j = C, L = j.next; - (j.next = null), null === x ? (w = L) : (x.next = L), (x = j); + ((j.next = null), null === x ? (w = L) : (x.next = L), (x = j)); var B = s.alternate; null !== B && (C = (B = B.updateQueue).lastBaseUpdate) !== x && @@ -19085,7 +19115,7 @@ 0 !== C.lane && ((s.flags |= 64), null === (V = _.effects) ? (_.effects = [C]) : V.push(C)); } else - (U = { + ((U = { eventTime: U, lane: V, tag: C.tag, @@ -19094,13 +19124,13 @@ next: null }), null === B ? ((L = B = U), (j = $)) : (B = B.next = U), - (x |= V); + (x |= V)); if (null === (C = C.next)) { if (null === (C = _.shared.pending)) break; - (C = (V = C).next), + ((C = (V = C).next), (V.next = null), (_.lastBaseUpdate = V), - (_.shared.pending = null); + (_.shared.pending = null)); } } if ( @@ -19112,10 +19142,10 @@ ) { _ = o; do { - (x |= _.lane), (_ = _.next); + ((x |= _.lane), (_ = _.next)); } while (_ !== o); } else null === w && (_.shared.lanes = 0); - (Ks |= x), (s.lanes = x), (s.memoizedState = $); + ((Ks |= x), (s.lanes = x), (s.memoizedState = $)); } } function sh(s, o, i) { @@ -19150,10 +19180,10 @@ (s = s.tagName) ); } - E(Xn), G(Xn, o); + (E(Xn), G(Xn, o)); } function zh() { - E(Xn), E(Zn), E(Qn); + (E(Xn), E(Zn), E(Qn)); } function Ah(s) { xh(Qn.current); @@ -19177,7 +19207,7 @@ } else if (19 === o.tag && void 0 !== o.memoizedProps.revealOrder) { if (128 & o.flags) return o; } else if (null !== o.child) { - (o.child.return = o), (o = o.child); + ((o.child.return = o), (o = o.child)); continue; } if (o === s) break; @@ -19185,7 +19215,7 @@ if (null === o.return || o.return === s) return null; o = o.return; } - (o.sibling.return = o.return), (o = o.sibling); + ((o.sibling.return = o.return), (o = o.sibling)); } return null; } @@ -19226,11 +19256,11 @@ w = 0; do { if (((us = !1), (ps = 0), 25 <= w)) throw Error(p(301)); - (w += 1), + ((w += 1), (ls = as = null), (o.updateQueue = null), (rs.current = gs), - (s = i(u, _)); + (s = i(u, _))); } while (us); } if ( @@ -19246,7 +19276,7 @@ } function Sh() { var s = 0 !== ps; - return (ps = 0), s; + return ((ps = 0), s); } function Th() { var s = { @@ -19256,7 +19286,7 @@ queue: null, next: null }; - return null === ls ? (os.memoizedState = ls = s) : (ls = ls.next = s), ls; + return (null === ls ? (os.memoizedState = ls = s) : (ls = ls.next = s), ls); } function Uh() { if (null === as) { @@ -19264,17 +19294,17 @@ s = null !== s ? s.memoizedState : null; } else s = as.next; var o = null === ls ? os.memoizedState : ls.next; - if (null !== o) (ls = o), (as = s); + if (null !== o) ((ls = o), (as = s)); else { if (null === s) throw Error(p(310)); - (s = { + ((s = { memoizedState: (as = s).memoizedState, baseState: as.baseState, baseQueue: as.baseQueue, queue: as.queue, next: null }), - null === ls ? (os.memoizedState = ls = s) : (ls = ls.next = s); + null === ls ? (os.memoizedState = ls = s) : (ls = ls.next = s)); } return ls; } @@ -19292,19 +19322,19 @@ if (null !== w) { if (null !== _) { var x = _.next; - (_.next = w.next), (w.next = x); + ((_.next = w.next), (w.next = x)); } - (u.baseQueue = _ = w), (i.pending = null); + ((u.baseQueue = _ = w), (i.pending = null)); } if (null !== _) { - (w = _.next), (u = u.baseState); + ((w = _.next), (u = u.baseState)); var C = (x = null), j = null, L = w; do { var B = L.lane; if ((ss & B) === B) - null !== j && + (null !== j && (j = j.next = { lane: 0, @@ -19313,7 +19343,7 @@ eagerState: L.eagerState, next: null }), - (u = L.hasEagerState ? L.eagerState : s(u, L.action)); + (u = L.hasEagerState ? L.eagerState : s(u, L.action))); else { var $ = { lane: B, @@ -19322,23 +19352,23 @@ eagerState: L.eagerState, next: null }; - null === j ? ((C = j = $), (x = u)) : (j = j.next = $), + (null === j ? ((C = j = $), (x = u)) : (j = j.next = $), (os.lanes |= B), - (Ks |= B); + (Ks |= B)); } L = L.next; } while (null !== L && L !== w); - null === j ? (x = u) : (j.next = C), + (null === j ? (x = u) : (j.next = C), Lr(u, o.memoizedState) || (_s = !0), (o.memoizedState = u), (o.baseState = x), (o.baseQueue = j), - (i.lastRenderedState = u); + (i.lastRenderedState = u)); } if (null !== (s = i.interleaved)) { _ = s; do { - (w = _.lane), (os.lanes |= w), (Ks |= w), (_ = _.next); + ((w = _.lane), (os.lanes |= w), (Ks |= w), (_ = _.next)); } while (_ !== s); } else null === _ && (i.lanes = 0); return [o.memoizedState, i.dispatch]; @@ -19355,12 +19385,12 @@ i.pending = null; var x = (_ = _.next); do { - (w = s(w, x.action)), (x = x.next); + ((w = s(w, x.action)), (x = x.next)); } while (x !== _); - Lr(w, o.memoizedState) || (_s = !0), + (Lr(w, o.memoizedState) || (_s = !0), (o.memoizedState = w), null === o.baseQueue && (o.baseState = w), - (i.lastRenderedState = w); + (i.lastRenderedState = w)); } return [w, u]; } @@ -19383,16 +19413,16 @@ return _; } function di(s, o, i) { - (s.flags |= 16384), + ((s.flags |= 16384), (s = { getSnapshot: o, value: i }), null === (o = os.updateQueue) ? ((o = { lastEffect: null, stores: null }), (os.updateQueue = o), (o.stores = [s])) : null === (i = o.stores) ? (o.stores = [s]) - : i.push(s); + : i.push(s)); } function ci(s, o, i, u) { - (o.value = i), (o.getSnapshot = u), ei(o) && fi(s); + ((o.value = i), (o.getSnapshot = u), ei(o) && fi(s)); } function ai(s, o, i) { return i(function () { @@ -19449,7 +19479,7 @@ } function ki(s, o, i, u) { var _ = Th(); - (os.flags |= s), (_.memoizedState = bi(1 | o, i, void 0, void 0 === u ? null : u)); + ((os.flags |= s), (_.memoizedState = bi(1 | o, i, void 0, void 0 === u ? null : u))); } function li(s, o, i, u) { var _ = Uh(); @@ -19460,7 +19490,7 @@ if (((w = x.destroy), null !== u && Mh(u, x.deps))) return void (_.memoizedState = bi(o, i, w, u)); } - (os.flags |= s), (_.memoizedState = bi(1 | o, i, w, u)); + ((os.flags |= s), (_.memoizedState = bi(1 | o, i, w, u))); } function mi(s, o) { return ki(8390656, 8, s, o); @@ -19490,7 +19520,7 @@ : void 0; } function qi(s, o, i) { - return (i = null != i ? i.concat([s]) : null), li(4, 4, pi.bind(null, o, s), i); + return ((i = null != i ? i.concat([s]) : null), li(4, 4, pi.bind(null, o, s), i)); } function ri() {} function si(s, o) { @@ -19514,13 +19544,13 @@ } function vi(s, o) { var i = At; - (At = 0 !== i && 4 > i ? i : 4), s(!0); + ((At = 0 !== i && 4 > i ? i : 4), s(!0)); var u = ns.transition; ns.transition = {}; try { - s(!1), o(); + (s(!1), o()); } finally { - (At = i), (ns.transition = u); + ((At = i), (ns.transition = u)); } } function wi() { @@ -19533,7 +19563,7 @@ ) Ai(o, i); else if (null !== (i = hh(s, o, i, u))) { - gi(i, s, u, R()), Bi(i, o, u); + (gi(i, s, u, R()), Bi(i, o, u)); } } function ii(s, o, i) { @@ -19568,12 +19598,12 @@ function Ai(s, o) { us = cs = !0; var i = s.pending; - null === i ? (o.next = o) : ((o.next = i.next), (i.next = o)), (s.pending = o); + (null === i ? (o.next = o) : ((o.next = i.next), (i.next = o)), (s.pending = o)); } function Bi(s, o, i) { if (4194240 & i) { var u = o.lanes; - (i |= u &= s.pendingLanes), (o.lanes = i), Cc(s, i); + ((i |= u &= s.pendingLanes), (o.lanes = i), Cc(s, i)); } } var ds = { @@ -19599,13 +19629,14 @@ fs = { readContext: eh, useCallback: function (s, o) { - return (Th().memoizedState = [s, void 0 === o ? null : o]), s; + return ((Th().memoizedState = [s, void 0 === o ? null : o]), s); }, useContext: eh, useEffect: mi, useImperativeHandle: function (s, o, i) { return ( - (i = null != i ? i.concat([s]) : null), ki(4194308, 4, pi.bind(null, o, s), i) + (i = null != i ? i.concat([s]) : null), + ki(4194308, 4, pi.bind(null, o, s), i) ); }, useLayoutEffect: function (s, o) { @@ -19616,7 +19647,7 @@ }, useMemo: function (s, o) { var i = Th(); - return (o = void 0 === o ? null : o), (s = s()), (i.memoizedState = [s, o]), s; + return ((o = void 0 === o ? null : o), (s = s()), (i.memoizedState = [s, o]), s); }, useReducer: function (s, o, i) { var u = Th(); @@ -19637,7 +19668,7 @@ ); }, useRef: function (s) { - return (s = { current: s }), (Th().memoizedState = s); + return ((s = { current: s }), (Th().memoizedState = s)); }, useState: hi, useDebugValue: ri, @@ -19647,7 +19678,7 @@ useTransition: function () { var s = hi(!1), o = s[0]; - return (s = vi.bind(null, s[1])), (Th().memoizedState = s), [o, s]; + return ((s = vi.bind(null, s[1])), (Th().memoizedState = s), [o, s]); }, useMutableSource: function () {}, useSyncExternalStore: function (s, o, i) { @@ -19675,9 +19706,9 @@ o = Fs.identifierPrefix; if (Fn) { var i = Dn; - (o = ':' + o + 'R' + (i = (Rn & ~(1 << (32 - St(Rn) - 1))).toString(32) + i)), + ((o = ':' + o + 'R' + (i = (Rn & ~(1 << (32 - St(Rn) - 1))).toString(32) + i)), 0 < (i = ps++) && (o += 'H' + i.toString(32)), - (o += ':'); + (o += ':')); } else o = ':' + o + 'r' + (i = hs++).toString(32) + ':'; return (s.memoizedState = o); }, @@ -19745,9 +19776,9 @@ return o; } function Di(s, o, i, u) { - (i = null == (i = i(u, (o = s.memoizedState))) ? o : xe({}, o, i)), + ((i = null == (i = i(u, (o = s.memoizedState))) ? o : xe({}, o, i)), (s.memoizedState = i), - 0 === s.lanes && (s.updateQueue.baseState = i); + 0 === s.lanes && (s.updateQueue.baseState = i)); } var ys = { isMounted: function (s) { @@ -19758,28 +19789,28 @@ var u = R(), _ = yi(s), w = mh(u, _); - (w.payload = o), + ((w.payload = o), null != i && (w.callback = i), - null !== (o = nh(s, w, _)) && (gi(o, s, _, u), oh(o, s, _)); + null !== (o = nh(s, w, _)) && (gi(o, s, _, u), oh(o, s, _))); }, enqueueReplaceState: function (s, o, i) { s = s._reactInternals; var u = R(), _ = yi(s), w = mh(u, _); - (w.tag = 1), + ((w.tag = 1), (w.payload = o), null != i && (w.callback = i), - null !== (o = nh(s, w, _)) && (gi(o, s, _, u), oh(o, s, _)); + null !== (o = nh(s, w, _)) && (gi(o, s, _, u), oh(o, s, _))); }, enqueueForceUpdate: function (s, o) { s = s._reactInternals; var i = R(), u = yi(s), _ = mh(i, u); - (_.tag = 2), + ((_.tag = 2), null != o && (_.callback = o), - null !== (o = nh(s, _, u)) && (gi(o, s, u, i), oh(o, s, u)); + null !== (o = nh(s, _, u)) && (gi(o, s, u, i), oh(o, s, u))); } }; function Fi(s, o, i, u, _, w, x) { @@ -19808,17 +19839,17 @@ ); } function Hi(s, o, i, u) { - (s = o.state), + ((s = o.state), 'function' == typeof o.componentWillReceiveProps && o.componentWillReceiveProps(i, u), 'function' == typeof o.UNSAFE_componentWillReceiveProps && o.UNSAFE_componentWillReceiveProps(i, u), - o.state !== s && ys.enqueueReplaceState(o, o.state, null); + o.state !== s && ys.enqueueReplaceState(o, o.state, null)); } function Ii(s, o, i, u) { var _ = s.stateNode; - (_.props = i), (_.state = s.memoizedState), (_.refs = {}), kh(s); + ((_.props = i), (_.state = s.memoizedState), (_.refs = {}), kh(s)); var w = o.contextType; - 'object' == typeof w && null !== w + ('object' == typeof w && null !== w ? (_.context = eh(w)) : ((w = Zf(o) ? xn : wn.current), (_.context = Yf(s, w))), (_.state = s.memoizedState), @@ -19834,14 +19865,14 @@ o !== _.state && ys.enqueueReplaceState(_, _.state, null), qh(s, i, _, u), (_.state = s.memoizedState)), - 'function' == typeof _.componentDidMount && (s.flags |= 4194308); + 'function' == typeof _.componentDidMount && (s.flags |= 4194308)); } function Ji(s, o) { try { var i = '', u = o; do { - (i += Pa(u)), (u = u.return); + ((i += Pa(u)), (u = u.return)); } while (u); var _ = i; } catch (s) { @@ -19868,11 +19899,11 @@ } var vs = 'function' == typeof WeakMap ? WeakMap : Map; function Ni(s, o, i) { - ((i = mh(-1, i)).tag = 3), (i.payload = { element: null }); + (((i = mh(-1, i)).tag = 3), (i.payload = { element: null })); var u = o.value; return ( (i.callback = function () { - eo || ((eo = !0), (to = u)), Li(0, o); + (eo || ((eo = !0), (to = u)), Li(0, o)); }), i ); @@ -19882,20 +19913,21 @@ var u = s.type.getDerivedStateFromError; if ('function' == typeof u) { var _ = o.value; - (i.payload = function () { + ((i.payload = function () { return u(_); }), (i.callback = function () { Li(0, o); - }); + })); } var w = s.stateNode; return ( null !== w && 'function' == typeof w.componentDidCatch && (i.callback = function () { - Li(0, o), - 'function' != typeof u && (null === ro ? (ro = new Set([this])) : ro.add(this)); + (Li(0, o), + 'function' != typeof u && + (null === ro ? (ro = new Set([this])) : ro.add(this))); var s = o.stack; this.componentDidCatch(o.value, { componentStack: null !== s ? s : '' }); }), @@ -19977,14 +20009,14 @@ if ((i = null !== (i = i.compare) ? i : Ie)(x, u) && s.ref === o.ref) return Zi(s, o, _); } - return (o.flags |= 1), ((s = Pg(w, u)).ref = o.ref), (s.return = o), (o.child = s); + return ((o.flags |= 1), ((s = Pg(w, u)).ref = o.ref), (s.return = o), (o.child = s)); } function bj(s, o, i, u, _) { if (null !== s) { var w = s.memoizedProps; if (Ie(w, u) && s.ref === o.ref) { if (((_s = !1), (o.pendingProps = u = w), !(s.lanes & _))) - return (o.lanes = s.lanes), Zi(s, o, _); + return ((o.lanes = s.lanes), Zi(s, o, _)); 131072 & s.flags && (_s = !0); } } @@ -20006,19 +20038,19 @@ (Vs |= s), null ); - (o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), + ((o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), (u = null !== w ? w.baseLanes : i), G(Us, Vs), - (Vs |= u); + (Vs |= u)); } else - (o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), + ((o.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }), G(Us, Vs), - (Vs |= i); + (Vs |= i)); else - null !== w ? ((u = w.baseLanes | i), (o.memoizedState = null)) : (u = i), + (null !== w ? ((u = w.baseLanes | i), (o.memoizedState = null)) : (u = i), G(Us, Vs), - (Vs |= u); - return Xi(s, o, _, i), o.child; + (Vs |= u)); + return (Xi(s, o, _, i), o.child); } function gj(s, o) { var i = o.ref; @@ -20045,7 +20077,7 @@ var w = !0; cg(o); } else w = !1; - if ((ch(o, _), null === o.stateNode)) ij(s, o), Gi(o, i, u), Ii(o, i, u, _), (u = !0); + if ((ch(o, _), null === o.stateNode)) (ij(s, o), Gi(o, i, u), Ii(o, i, u, _), (u = !0)); else if (null === s) { var x = o.stateNode, C = o.memoizedProps; @@ -20057,13 +20089,13 @@ : (L = Yf(o, (L = Zf(i) ? xn : wn.current))); var B = i.getDerivedStateFromProps, $ = 'function' == typeof B || 'function' == typeof x.getSnapshotBeforeUpdate; - $ || + ($ || ('function' != typeof x.UNSAFE_componentWillReceiveProps && 'function' != typeof x.componentWillReceiveProps) || ((C !== u || j !== L) && Hi(o, x, u, L)), - (Gn = !1); + (Gn = !1)); var V = o.memoizedState; - (x.state = V), + ((x.state = V), qh(o, u, x, _), (j = o.memoizedState), C !== u || V !== j || Sn.current || Gn @@ -20083,9 +20115,9 @@ (x.state = j), (x.context = L), (u = C)) - : ('function' == typeof x.componentDidMount && (o.flags |= 4194308), (u = !1)); + : ('function' == typeof x.componentDidMount && (o.flags |= 4194308), (u = !1))); } else { - (x = o.stateNode), + ((x = o.stateNode), lh(s, o), (C = o.memoizedProps), (L = o.type === o.elementType ? C : Ci(o.type, C)), @@ -20094,16 +20126,16 @@ (V = x.context), 'object' == typeof (j = i.contextType) && null !== j ? (j = eh(j)) - : (j = Yf(o, (j = Zf(i) ? xn : wn.current))); + : (j = Yf(o, (j = Zf(i) ? xn : wn.current)))); var U = i.getDerivedStateFromProps; - (B = 'function' == typeof U || 'function' == typeof x.getSnapshotBeforeUpdate) || + ((B = 'function' == typeof U || 'function' == typeof x.getSnapshotBeforeUpdate) || ('function' != typeof x.UNSAFE_componentWillReceiveProps && 'function' != typeof x.componentWillReceiveProps) || ((C !== $ || V !== j) && Hi(o, x, u, j)), (Gn = !1), (V = o.memoizedState), (x.state = V), - qh(o, u, x, _); + qh(o, u, x, _)); var z = o.memoizedState; C !== $ || V !== z || Sn.current || Gn ? ('function' == typeof U && (Di(o, i, U, u), (z = o.memoizedState)), @@ -20142,8 +20174,8 @@ function jj(s, o, i, u, _, w) { gj(s, o); var x = !!(128 & o.flags); - if (!u && !x) return _ && dg(o, i, !1), Zi(s, o, w); - (u = o.stateNode), (bs.current = o); + if (!u && !x) return (_ && dg(o, i, !1), Zi(s, o, w)); + ((u = o.stateNode), (bs.current = o)); var C = x && 'function' != typeof i.getDerivedStateFromError ? null : u.render(); return ( (o.flags |= 1), @@ -20157,13 +20189,13 @@ } function kj(s) { var o = s.stateNode; - o.pendingContext + (o.pendingContext ? ag(0, o.pendingContext, o.pendingContext !== o.context) : o.context && ag(0, o.context, !1), - yh(s, o.containerInfo); + yh(s, o.containerInfo)); } function lj(s, o, i, u, _) { - return Ig(), Jg(_), (o.flags |= 256), Xi(s, o, i, u), o.child; + return (Ig(), Jg(_), (o.flags |= 256), Xi(s, o, i, u), o.child); } var Es, ws, @@ -20237,7 +20269,7 @@ if (!(1 & o.mode)) return sj(s, o, x, null); if ('$!' === _.data) { if ((u = _.nextSibling && _.nextSibling.dataset)) var C = u.dgst; - return (u = C), sj(s, o, x, (u = Ki((w = Error(p(419))), u, void 0))); + return ((u = C), sj(s, o, x, (u = Ki((w = Error(p(419))), u, void 0)))); } if (((C = !!(x & s.childLanes)), _s || C)) { if (null !== (u = Fs)) { @@ -20281,7 +20313,7 @@ _ !== w.retryLane && ((w.retryLane = _), ih(s, _), gi(u, s, _, -1)); } - return tj(), sj(s, o, x, (u = Ki(Error(p(421))))); + return (tj(), sj(s, o, x, (u = Ki(Error(p(421)))))); } return '$?' === _.data ? ((o.flags |= 128), @@ -20306,7 +20338,7 @@ o); })(s, o, C, _, u, w, i); if (x) { - (x = _.fallback), (C = o.mode), (u = (w = s.child).sibling); + ((x = _.fallback), (C = o.mode), (u = (w = s.child).sibling)); var j = { mode: 'hidden', children: _.children }; return ( 1 & C || o.child === w @@ -20360,7 +20392,7 @@ function vj(s, o, i) { s.lanes |= o; var u = s.alternate; - null !== u && (u.lanes |= o), bh(s.return, o, i); + (null !== u && (u.lanes |= o), bh(s.return, o, i)); } function wj(s, o, i, u, _) { var w = s.memoizedState; @@ -20385,14 +20417,14 @@ _ = u.revealOrder, w = u.tail; if ((Xi(s, o, u.children, i), 2 & (u = es.current))) - (u = (1 & u) | 2), (o.flags |= 128); + ((u = (1 & u) | 2), (o.flags |= 128)); else { if (null !== s && 128 & s.flags) e: for (s = o.child; null !== s; ) { if (13 === s.tag) null !== s.memoizedState && vj(s, i, o); else if (19 === s.tag) vj(s, i, o); else if (null !== s.child) { - (s.child.return = s), (s = s.child); + ((s.child.return = s), (s = s.child)); continue; } if (s === o) break e; @@ -20400,7 +20432,7 @@ if (null === s.return || s.return === o) break e; s = s.return; } - (s.sibling.return = s.return), (s = s.sibling); + ((s.sibling.return = s.return), (s = s.sibling)); } u &= 1; } @@ -20408,11 +20440,11 @@ switch (_) { case 'forwards': for (i = o.child, _ = null; null !== i; ) - null !== (s = i.alternate) && null === Ch(s) && (_ = i), (i = i.sibling); - null === (i = _) + (null !== (s = i.alternate) && null === Ch(s) && (_ = i), (i = i.sibling)); + (null === (i = _) ? ((_ = o.child), (o.child = null)) : ((_ = i.sibling), (i.sibling = null)), - wj(o, !1, _, i, w); + wj(o, !1, _, i, w)); break; case 'backwards': for (i = null, _ = o.child, o.child = null; null !== _; ) { @@ -20420,7 +20452,7 @@ o.child = _; break; } - (s = _.sibling), (_.sibling = i), (i = _), (_ = s); + ((s = _.sibling), (_.sibling = i), (i = _), (_ = s)); } wj(o, !0, i, null, w); break; @@ -20450,9 +20482,8 @@ for ( i = Pg((s = o.child), s.pendingProps), o.child = i, i.return = o; null !== s.sibling; - ) - (s = s.sibling), ((i = i.sibling = Pg(s, s.pendingProps)).return = o); + ((s = s.sibling), ((i = i.sibling = Pg(s, s.pendingProps)).return = o)); i.sibling = null; } return o.child; @@ -20462,12 +20493,14 @@ switch (s.tailMode) { case 'hidden': o = s.tail; - for (var i = null; null !== o; ) null !== o.alternate && (i = o), (o = o.sibling); + for (var i = null; null !== o; ) + (null !== o.alternate && (i = o), (o = o.sibling)); null === i ? (s.tail = null) : (i.sibling = null); break; case 'collapsed': i = s.tail; - for (var u = null; null !== i; ) null !== i.alternate && (u = i), (i = i.sibling); + for (var u = null; null !== i; ) + (null !== i.alternate && (u = i), (i = i.sibling)); null === u ? o || null === s.tail ? (s.tail = null) @@ -20481,19 +20514,19 @@ u = 0; if (o) for (var _ = s.child; null !== _; ) - (i |= _.lanes | _.childLanes), + ((i |= _.lanes | _.childLanes), (u |= 14680064 & _.subtreeFlags), (u |= 14680064 & _.flags), (_.return = s), - (_ = _.sibling); + (_ = _.sibling)); else for (_ = s.child; null !== _; ) - (i |= _.lanes | _.childLanes), + ((i |= _.lanes | _.childLanes), (u |= _.subtreeFlags), (u |= _.flags), (_.return = s), - (_ = _.sibling); - return (s.subtreeFlags |= u), (s.childLanes = i), o; + (_ = _.sibling)); + return ((s.subtreeFlags |= u), (s.childLanes = i), o); } function Ej(s, o, i) { var u = o.pendingProps; @@ -20508,10 +20541,10 @@ case 12: case 9: case 14: - return S(o), null; + return (S(o), null); case 1: case 17: - return Zf(o.type) && $f(), S(o), null; + return (Zf(o.type) && $f(), S(o), null); case 3: return ( (u = o.stateNode), @@ -20534,18 +20567,18 @@ Bh(o); var _ = xh(Qn.current); if (((i = o.type), null !== s && null != o.stateNode)) - Ss(s, o, i, u, _), s.ref !== o.ref && ((o.flags |= 512), (o.flags |= 2097152)); + (Ss(s, o, i, u, _), s.ref !== o.ref && ((o.flags |= 512), (o.flags |= 2097152))); else { if (!u) { if (null === o.stateNode) throw Error(p(166)); - return S(o), null; + return (S(o), null); } if (((s = xh(Xn.current)), Gg(o))) { - (u = o.stateNode), (i = o.type); + ((u = o.stateNode), (i = o.type)); var w = o.memoizedProps; switch (((u[dn] = o), (u[fn] = w), (s = !!(1 & o.mode)), i)) { case 'dialog': - D('cancel', u), D('close', u); + (D('cancel', u), D('close', u)); break; case 'iframe': case 'object': @@ -20562,19 +20595,19 @@ case 'img': case 'image': case 'link': - D('error', u), D('load', u); + (D('error', u), D('load', u)); break; case 'details': D('toggle', u); break; case 'input': - Za(u, w), D('invalid', u); + (Za(u, w), D('invalid', u)); break; case 'select': - (u._wrapperState = { wasMultiple: !!w.multiple }), D('invalid', u); + ((u._wrapperState = { wasMultiple: !!w.multiple }), D('invalid', u)); break; case 'textarea': - hb(u, w), D('invalid', u); + (hb(u, w), D('invalid', u)); } for (var C in (ub(i, w), (_ = null), w)) if (w.hasOwnProperty(C)) { @@ -20592,10 +20625,10 @@ } switch (i) { case 'input': - Va(u), db(u, w, !0); + (Va(u), db(u, w, !0)); break; case 'textarea': - Va(u), jb(u); + (Va(u), jb(u)); break; case 'select': case 'option': @@ -20603,9 +20636,9 @@ default: 'function' == typeof w.onClick && (u.onclick = Bf); } - (u = _), (o.updateQueue = u), null !== u && (o.flags |= 4); + ((u = _), (o.updateQueue = u), null !== u && (o.flags |= 4)); } else { - (C = 9 === _.nodeType ? _ : _.ownerDocument), + ((C = 9 === _.nodeType ? _ : _.ownerDocument), 'http://www.w3.org/1999/xhtml' === s && (s = kb(i)), 'http://www.w3.org/1999/xhtml' === s ? 'script' === i @@ -20621,16 +20654,16 @@ (s[dn] = o), (s[fn] = u), Es(s, o, !1, !1), - (o.stateNode = s); + (o.stateNode = s)); e: { switch (((C = vb(i, u)), i)) { case 'dialog': - D('cancel', s), D('close', s), (_ = u); + (D('cancel', s), D('close', s), (_ = u)); break; case 'iframe': case 'object': case 'embed': - D('load', s), (_ = u); + (D('load', s), (_ = u)); break; case 'video': case 'audio': @@ -20638,30 +20671,30 @@ _ = u; break; case 'source': - D('error', s), (_ = u); + (D('error', s), (_ = u)); break; case 'img': case 'image': case 'link': - D('error', s), D('load', s), (_ = u); + (D('error', s), D('load', s), (_ = u)); break; case 'details': - D('toggle', s), (_ = u); + (D('toggle', s), (_ = u)); break; case 'input': - Za(s, u), (_ = Ya(s, u)), D('invalid', s); + (Za(s, u), (_ = Ya(s, u)), D('invalid', s)); break; case 'option': default: _ = u; break; case 'select': - (s._wrapperState = { wasMultiple: !!u.multiple }), + ((s._wrapperState = { wasMultiple: !!u.multiple }), (_ = xe({}, u, { value: void 0 })), - D('invalid', s); + D('invalid', s)); break; case 'textarea': - hb(s, u), (_ = gb(s, u)), D('invalid', s); + (hb(s, u), (_ = gb(s, u)), D('invalid', s)); } for (w in (ub(i, _), (j = _))) if (j.hasOwnProperty(w)) { @@ -20683,19 +20716,19 @@ } switch (i) { case 'input': - Va(s), db(s, u, !1); + (Va(s), db(s, u, !1)); break; case 'textarea': - Va(s), jb(s); + (Va(s), jb(s)); break; case 'option': null != u.value && s.setAttribute('value', '' + Sa(u.value)); break; case 'select': - (s.multiple = !!u.multiple), + ((s.multiple = !!u.multiple), null != (w = u.value) ? fb(s, !!u.multiple, w, !1) - : null != u.defaultValue && fb(s, !!u.multiple, u.defaultValue, !0); + : null != u.defaultValue && fb(s, !!u.multiple, u.defaultValue, !0)); break; default: 'function' == typeof _.onClick && (s.onclick = Bf); @@ -20718,7 +20751,7 @@ } null !== o.ref && ((o.flags |= 512), (o.flags |= 2097152)); } - return S(o), null; + return (S(o), null); case 6: if (s && null != o.stateNode) xs(s, o, s.memoizedProps, u); else { @@ -20740,10 +20773,10 @@ } w && (o.flags |= 4); } else - ((u = (9 === i.nodeType ? i : i.ownerDocument).createTextNode(u))[dn] = o), - (o.stateNode = u); + (((u = (9 === i.nodeType ? i : i.ownerDocument).createTextNode(u))[dn] = o), + (o.stateNode = u)); } - return S(o), null; + return (S(o), null); case 13: if ( (E(es), @@ -20751,16 +20784,16 @@ null === s || (null !== s.memoizedState && null !== s.memoizedState.dehydrated)) ) { if (Fn && null !== Bn && 1 & o.mode && !(128 & o.flags)) - Hg(), Ig(), (o.flags |= 98560), (w = !1); + (Hg(), Ig(), (o.flags |= 98560), (w = !1)); else if (((w = Gg(o)), null !== u && null !== u.dehydrated)) { if (null === s) { if (!w) throw Error(p(318)); if (!(w = null !== (w = o.memoizedState) ? w.dehydrated : null)) throw Error(p(317)); w[dn] = o; - } else Ig(), !(128 & o.flags) && (o.memoizedState = null), (o.flags |= 4); - S(o), (w = !1); - } else null !== qn && (Fj(qn), (qn = null)), (w = !0); + } else (Ig(), !(128 & o.flags) && (o.memoizedState = null), (o.flags |= 4)); + (S(o), (w = !1)); + } else (null !== qn && (Fj(qn), (qn = null)), (w = !0)); if (!w) return 65536 & o.flags ? o : null; } return 128 & o.flags @@ -20773,11 +20806,11 @@ S(o), null); case 4: - return zh(), ws(s, o), null === s && sf(o.stateNode.containerInfo), S(o), null; + return (zh(), ws(s, o), null === s && sf(o.stateNode.containerInfo), S(o), null); case 10: - return ah(o.type._context), S(o), null; + return (ah(o.type._context), S(o), null); case 19: - if ((E(es), null === (w = o.memoizedState))) return S(o), null; + if ((E(es), null === (w = o.memoizedState))) return (S(o), null); if (((u = !!(128 & o.flags)), null === (C = w.rendering))) if (u) Dj(w, !1); else { @@ -20792,9 +20825,8 @@ u = i, i = o.child; null !== i; - ) - (s = u), + ((s = u), ((w = i).flags &= 14680066), null === (C = w.alternate) ? ((w.childLanes = 0), @@ -20820,8 +20852,8 @@ null === s ? null : { lanes: s.lanes, firstContext: s.firstContext })), - (i = i.sibling); - return G(es, (1 & es.current) | 2), o.child; + (i = i.sibling)); + return (G(es, (1 & es.current) | 2), o.child); } s = s.sibling; } @@ -20839,7 +20871,7 @@ Dj(w, !0), null === w.tail && 'hidden' === w.tailMode && !C.alternate && !Fn) ) - return S(o), null; + return (S(o), null); } else 2 * dt() - w.renderingStartTime > Zs && 1073741824 !== i && @@ -20891,7 +20923,7 @@ 65536 & (s = o.flags) && !(128 & s) ? ((o.flags = (-65537 & s) | 128), o) : null ); case 5: - return Bh(o), null; + return (Bh(o), null); case 13: if ((E(es), null !== (s = o.memoizedState) && null !== s.dehydrated)) { if (null === o.alternate) throw Error(p(340)); @@ -20899,23 +20931,23 @@ } return 65536 & (s = o.flags) ? ((o.flags = (-65537 & s) | 128), o) : null; case 19: - return E(es), null; + return (E(es), null); case 4: - return zh(), null; + return (zh(), null); case 10: - return ah(o.type._context), null; + return (ah(o.type._context), null); case 22: case 23: - return Hj(), null; + return (Hj(), null); default: return null; } } - (Es = function (s, o) { + ((Es = function (s, o) { for (var i = o.child; null !== i; ) { if (5 === i.tag || 6 === i.tag) s.appendChild(i.stateNode); else if (4 !== i.tag && null !== i.child) { - (i.child.return = i), (i = i.child); + ((i.child.return = i), (i = i.child)); continue; } if (i === o) break; @@ -20923,27 +20955,27 @@ if (null === i.return || i.return === o) return; i = i.return; } - (i.sibling.return = i.return), (i = i.sibling); + ((i.sibling.return = i.return), (i = i.sibling)); } }), (ws = function () {}), (Ss = function (s, o, i, u) { var _ = s.memoizedProps; if (_ !== u) { - (s = o.stateNode), xh(Xn.current); + ((s = o.stateNode), xh(Xn.current)); var w, C = null; switch (i) { case 'input': - (_ = Ya(s, _)), (u = Ya(s, u)), (C = []); + ((_ = Ya(s, _)), (u = Ya(s, u)), (C = [])); break; case 'select': - (_ = xe({}, _, { value: void 0 })), + ((_ = xe({}, _, { value: void 0 })), (u = xe({}, u, { value: void 0 })), - (C = []); + (C = [])); break; case 'textarea': - (_ = gb(s, _)), (u = gb(s, u)), (C = []); + ((_ = gb(s, _)), (u = gb(s, u)), (C = [])); break; default: 'function' != typeof _.onClick && @@ -20976,7 +21008,7 @@ (i || (i = {}), (i[w] = '')); for (w in L) L.hasOwnProperty(w) && j[w] !== L[w] && (i || (i = {}), (i[w] = L[w])); - } else i || (C || (C = []), C.push(B, i)), (i = L); + } else (i || (C || (C = []), C.push(B, i)), (i = L)); else 'dangerouslySetInnerHTML' === B ? ((L = L ? L.__html : void 0), @@ -20999,7 +21031,7 @@ }), (xs = function (s, o, i, u) { i !== u && (o.flags |= 4); - }); + })); var Cs = !1, Os = !1, As = 'function' == typeof WeakSet ? WeakSet : Set, @@ -21030,7 +21062,7 @@ do { if ((_.tag & s) === s) { var w = _.destroy; - (_.destroy = void 0), void 0 !== w && Mj(o, i, w); + ((_.destroy = void 0), void 0 !== w && Mj(o, i, w)); } _ = _.next; } while (_ !== u); @@ -21052,12 +21084,12 @@ var o = s.ref; if (null !== o) { var i = s.stateNode; - s.tag, (s = i), 'function' == typeof o ? o(s) : (o.current = s); + (s.tag, (s = i), 'function' == typeof o ? o(s) : (o.current = s)); } } function Sj(s) { var o = s.alternate; - null !== o && ((s.alternate = null), Sj(o)), + (null !== o && ((s.alternate = null), Sj(o)), (s.child = null), (s.deletions = null), (s.sibling = null), @@ -21071,7 +21103,7 @@ (s.memoizedState = null), (s.pendingProps = null), (s.stateNode = null), - (s.updateQueue = null); + (s.updateQueue = null)); } function Tj(s) { return 5 === s.tag || 3 === s.tag || 4 === s.tag; @@ -21085,11 +21117,10 @@ for ( s.sibling.return = s.return, s = s.sibling; 5 !== s.tag && 6 !== s.tag && 18 !== s.tag; - ) { if (2 & s.flags) continue e; if (null === s.child || 4 === s.tag) continue e; - (s.child.return = s), (s = s.child); + ((s.child.return = s), (s = s.child)); } if (!(2 & s.flags)) return s.stateNode; } @@ -21097,7 +21128,7 @@ function Vj(s, o, i) { var u = s.tag; if (5 === u || 6 === u) - (s = s.stateNode), + ((s = s.stateNode), o ? 8 === i.nodeType ? i.parentNode.insertBefore(s, o) @@ -21105,20 +21136,21 @@ : (8 === i.nodeType ? (o = i.parentNode).insertBefore(s, i) : (o = i).appendChild(s), - null != (i = i._reactRootContainer) || null !== o.onclick || (o.onclick = Bf)); + null != (i = i._reactRootContainer) || null !== o.onclick || (o.onclick = Bf))); else if (4 !== u && null !== (s = s.child)) - for (Vj(s, o, i), s = s.sibling; null !== s; ) Vj(s, o, i), (s = s.sibling); + for (Vj(s, o, i), s = s.sibling; null !== s; ) (Vj(s, o, i), (s = s.sibling)); } function Wj(s, o, i) { var u = s.tag; - if (5 === u || 6 === u) (s = s.stateNode), o ? i.insertBefore(s, o) : i.appendChild(s); + if (5 === u || 6 === u) + ((s = s.stateNode), o ? i.insertBefore(s, o) : i.appendChild(s)); else if (4 !== u && null !== (s = s.child)) - for (Wj(s, o, i), s = s.sibling; null !== s; ) Wj(s, o, i), (s = s.sibling); + for (Wj(s, o, i), s = s.sibling; null !== s; ) (Wj(s, o, i), (s = s.sibling)); } var Ps = null, Ms = !1; function Yj(s, o, i) { - for (i = i.child; null !== i; ) Zj(s, o, i), (i = i.sibling); + for (i = i.child; null !== i; ) (Zj(s, o, i), (i = i.sibling)); } function Zj(s, o, i) { if (wt && 'function' == typeof wt.onCommitFiberUnmount) @@ -21131,7 +21163,7 @@ case 6: var u = Ps, _ = Ms; - (Ps = null), + ((Ps = null), Yj(s, o, i), (Ms = _), null !== (Ps = u) && @@ -21139,7 +21171,7 @@ ? ((s = Ps), (i = i.stateNode), 8 === s.nodeType ? s.parentNode.removeChild(i) : s.removeChild(i)) - : Ps.removeChild(i.stateNode)); + : Ps.removeChild(i.stateNode))); break; case 18: null !== Ps && @@ -21151,13 +21183,13 @@ : Kf(Ps, i.stateNode)); break; case 4: - (u = Ps), + ((u = Ps), (_ = Ms), (Ps = i.stateNode.containerInfo), (Ms = !0), Yj(s, o, i), (Ps = u), - (Ms = _); + (Ms = _)); break; case 0: case 11: @@ -21168,7 +21200,7 @@ do { var w = _, x = w.destroy; - (w = w.tag), void 0 !== x && (2 & w || 4 & w) && Mj(i, o, x), (_ = _.next); + ((w = w.tag), void 0 !== x && (2 & w || 4 & w) && Mj(i, o, x), (_ = _.next)); } while (_ !== u); } Yj(s, o, i); @@ -21176,9 +21208,9 @@ case 1: if (!Os && (Lj(i, o), 'function' == typeof (u = i.stateNode).componentWillUnmount)) try { - (u.props = i.memoizedProps), + ((u.props = i.memoizedProps), (u.state = i.memoizedState), - u.componentWillUnmount(); + u.componentWillUnmount()); } catch (s) { W(i, o, s); } @@ -21201,11 +21233,11 @@ if (null !== o) { s.updateQueue = null; var i = s.stateNode; - null === i && (i = s.stateNode = new As()), + (null === i && (i = s.stateNode = new As()), o.forEach(function (o) { var u = bk.bind(null, s, o); i.has(o) || (i.add(o), o.then(u, u)); - }); + })); } } function ck(s, o) { @@ -21220,24 +21252,24 @@ e: for (; null !== C; ) { switch (C.tag) { case 5: - (Ps = C.stateNode), (Ms = !1); + ((Ps = C.stateNode), (Ms = !1)); break e; case 3: case 4: - (Ps = C.stateNode.containerInfo), (Ms = !0); + ((Ps = C.stateNode.containerInfo), (Ms = !0)); break e; } C = C.return; } if (null === Ps) throw Error(p(160)); - Zj(w, x, _), (Ps = null), (Ms = !1); + (Zj(w, x, _), (Ps = null), (Ms = !1)); var j = _.alternate; - null !== j && (j.return = null), (_.return = null); + (null !== j && (j.return = null), (_.return = null)); } catch (s) { W(_, o, s); } } - if (12854 & o.subtreeFlags) for (o = o.child; null !== o; ) dk(o, s), (o = o.sibling); + if (12854 & o.subtreeFlags) for (o = o.child; null !== o; ) (dk(o, s), (o = o.sibling)); } function dk(s, o) { var i = s.alternate, @@ -21249,7 +21281,7 @@ case 15: if ((ck(o, s), ek(s), 4 & u)) { try { - Pj(3, s, s.return), Qj(3, s); + (Pj(3, s, s.return), Qj(3, s)); } catch (o) { W(s, s.return, o); } @@ -21261,7 +21293,7 @@ } break; case 1: - ck(o, s), ek(s), 512 & u && null !== i && Lj(i, i.return); + (ck(o, s), ek(s), 512 & u && null !== i && Lj(i, i.return)); break; case 5: if ((ck(o, s), ek(s), 512 & u && null !== i && Lj(i, i.return), 32 & s.flags)) { @@ -21279,7 +21311,7 @@ j = s.updateQueue; if (((s.updateQueue = null), null !== j)) try { - 'input' === C && 'radio' === w.type && null != w.name && ab(_, w), vb(C, x); + ('input' === C && 'radio' === w.type && null != w.name && ab(_, w), vb(C, x)); var L = vb(C, w); for (x = 0; x < j.length; x += 2) { var B = j[x], @@ -21319,7 +21351,7 @@ case 6: if ((ck(o, s), ek(s), 4 & u)) { if (null === s.stateNode) throw Error(p(162)); - (_ = s.stateNode), (w = s.memoizedProps); + ((_ = s.stateNode), (w = s.memoizedProps)); try { _.nodeValue = w; } catch (o) { @@ -21337,10 +21369,10 @@ break; case 4: default: - ck(o, s), ek(s); + (ck(o, s), ek(s)); break; case 13: - ck(o, s), + (ck(o, s), ek(s), 8192 & (_ = s.child).flags && ((w = null !== _.memoizedState), @@ -21348,7 +21380,7 @@ !w || (null !== _.alternate && null !== _.alternate.memoizedState) || (Xs = dt())), - 4 & u && ak(s); + 4 & u && ak(s)); break; case 22: if ( @@ -21373,12 +21405,12 @@ Lj(V, V.return); var z = V.stateNode; if ('function' == typeof z.componentWillUnmount) { - (u = V), (i = V.return); + ((u = V), (i = V.return)); try { - (o = u), + ((o = u), (z.props = o.memoizedProps), (z.state = o.memoizedState), - z.componentWillUnmount(); + z.componentWillUnmount()); } catch (s) { W(u, i, s); } @@ -21402,7 +21434,7 @@ if (null === B) { B = $; try { - (_ = $.stateNode), + ((_ = $.stateNode), L ? 'function' == typeof (w = _.style).setProperty ? w.setProperty('display', 'none', 'important') @@ -21412,7 +21444,7 @@ null != (j = $.memoizedProps.style) && j.hasOwnProperty('display') ? j.display : null), - (C.style.display = rb('display', x))); + (C.style.display = rb('display', x)))); } catch (o) { W(s, s.return, o); } @@ -21428,20 +21460,20 @@ ((22 !== $.tag && 23 !== $.tag) || null === $.memoizedState || $ === s) && null !== $.child ) { - ($.child.return = $), ($ = $.child); + (($.child.return = $), ($ = $.child)); continue; } if ($ === s) break e; for (; null === $.sibling; ) { if (null === $.return || $.return === s) break e; - B === $ && (B = null), ($ = $.return); + (B === $ && (B = null), ($ = $.return)); } - B === $ && (B = null), ($.sibling.return = $.return), ($ = $.sibling); + (B === $ && (B = null), ($.sibling.return = $.return), ($ = $.sibling)); } } break; case 19: - ck(o, s), ek(s), 4 & u && ak(s); + (ck(o, s), ek(s), 4 & u && ak(s)); case 21: } } @@ -21462,7 +21494,7 @@ switch (u.tag) { case 5: var _ = u.stateNode; - 32 & u.flags && (ob(_, ''), (u.flags &= -33)), Wj(s, Uj(s), _); + (32 & u.flags && (ob(_, ''), (u.flags &= -33)), Wj(s, Uj(s), _)); break; case 3: case 4: @@ -21480,7 +21512,7 @@ 4096 & o && (s.flags &= -4097); } function hk(s, o, i) { - (js = s), ik(s, o, i); + ((js = s), ik(s, o, i)); } function ik(s, o, i) { for (var u = !!(1 & s.mode); null !== js; ) { @@ -21495,14 +21527,14 @@ var L = Os; if (((Cs = x), (Os = j) && !L)) for (js = _; null !== js; ) - (j = (x = js).child), + ((j = (x = js).child), 22 === x.tag && null !== x.memoizedState ? jk(_) : null !== j ? ((j.return = x), (js = j)) - : jk(_); - for (; null !== w; ) (js = w), ik(w, o, i), (w = w.sibling); - (js = _), (Cs = C), (Os = L); + : jk(_)); + for (; null !== w; ) ((js = w), ik(w, o, i), (w = w.sibling)); + ((js = _), (Cs = C), (Os = L)); } kk(s); } else 8772 & _.subtreeFlags && null !== w ? ((w.return = _), (js = w)) : kk(s); @@ -21603,7 +21635,7 @@ break; } if (null !== (i = o.sibling)) { - (i.return = o.return), (js = i); + ((i.return = o.return), (js = i)); break; } js = o.return; @@ -21618,7 +21650,7 @@ } var i = o.sibling; if (null !== i) { - (i.return = o.return), (js = i); + ((i.return = o.return), (js = i)); break; } js = o.return; @@ -21673,7 +21705,7 @@ } var C = o.sibling; if (null !== C) { - (C.return = o.return), (js = C); + ((C.return = o.return), (js = C)); break; } js = o.return; @@ -21726,11 +21758,11 @@ } function gi(s, o, i, u) { if (50 < io) throw ((io = 0), (ao = null), Error(p(185))); - Ac(s, i, u), + (Ac(s, i, u), (2 & Bs && s === Fs) || (s === Fs && (!(2 & Bs) && (Hs |= i), 4 === zs && Ck(s, $s)), Dk(s, u), - 1 === i && 0 === Bs && !(1 & o.mode) && ((Zs = dt() + 500), Cn && jg())); + 1 === i && 0 === Bs && !(1 & o.mode) && ((Zs = dt() + 500), Cn && jg()))); } function Dk(s, o) { var i = s.callbackNode; @@ -21741,30 +21773,29 @@ _ = s.expirationTimes, w = s.pendingLanes; 0 < w; - ) { var x = 31 - St(w), C = 1 << x, j = _[x]; - -1 === j + (-1 === j ? (C & i && !(C & u)) || (_[x] = vc(C, o)) : j <= o && (s.expiredLanes |= C), - (w &= ~C); + (w &= ~C)); } })(s, o); var u = uc(s, s === Fs ? $s : 0); - if (0 === u) null !== i && ut(i), (s.callbackNode = null), (s.callbackPriority = 0); + if (0 === u) (null !== i && ut(i), (s.callbackNode = null), (s.callbackPriority = 0)); else if (((o = u & -u), s.callbackPriority !== o)) { if ((null != i && ut(i), 1 === o)) - 0 === s.tag + (0 === s.tag ? (function ig(s) { - (Cn = !0), hg(s); + ((Cn = !0), hg(s)); })(Ek.bind(null, s)) : hg(Ek.bind(null, s)), pn(function () { !(6 & Bs) && jg(); }), - (i = null); + (i = null)); else { switch (Dc(u)) { case 1: @@ -21782,7 +21813,7 @@ } i = Fk(i, Gk.bind(null, s)); } - (s.callbackPriority = o), (s.callbackNode = i); + ((s.callbackPriority = o), (s.callbackNode = i)); } } function Gk(s, o) { @@ -21804,10 +21835,10 @@ } catch (o) { Mk(s, o); } - $g(), + ($g(), (Rs.current = w), (Bs = _), - null !== qs ? (o = 0) : ((Fs = null), ($s = 0), (o = zs)); + null !== qs ? (o = 0) : ((Fs = null), ($s = 0), (o = zs))); } if (0 !== o) { if ((2 === o && 0 !== (_ = xc(s)) && ((u = _), (o = Nk(s, _))), 1 === o)) @@ -21835,14 +21866,14 @@ } } if (((i = o.child), 16384 & o.subtreeFlags && null !== i)) - (i.return = o), (o = i); + ((i.return = o), (o = i)); else { if (o === s) break; for (; null === o.sibling; ) { if (null === o.return || o.return === s) return !0; o = o.return; } - (o.sibling.return = o.return), (o = o.sibling); + ((o.sibling.return = o.return), (o = o.sibling)); } } return !0; @@ -21865,7 +21896,7 @@ if ((Ck(s, u), (130023424 & u) === u && 10 < (o = Xs + 500 - dt()))) { if (0 !== uc(s, 0)) break; if (((_ = s.suspendedLanes) & u) !== u) { - R(), (s.pingedLanes |= s.suspendedLanes & _); + (R(), (s.pingedLanes |= s.suspendedLanes & _)); break; } s.timeoutHandle = ln(Pk.bind(null, s, Ys, Qs), o); @@ -21877,7 +21908,7 @@ if ((Ck(s, u), (4194240 & u) === u)) break; for (o = s.eventTimes, _ = -1; 0 < u; ) { var x = 31 - St(u); - (w = 1 << x), (x = o[x]) > _ && (_ = x), (u &= ~w); + ((w = 1 << x), (x = o[x]) > _ && (_ = x), (u &= ~w)); } if ( ((u = _), @@ -21907,7 +21938,7 @@ } } } - return Dk(s, dt()), s.callbackNode === i ? Gk.bind(null, s) : null; + return (Dk(s, dt()), s.callbackNode === i ? Gk.bind(null, s) : null); } function Nk(s, o) { var i = Gs; @@ -21924,18 +21955,17 @@ for ( o &= ~Js, o &= ~Hs, s.suspendedLanes |= o, s.pingedLanes &= ~o, s = s.expirationTimes; 0 < o; - ) { var i = 31 - St(o), u = 1 << i; - (s[i] = -1), (o &= ~u); + ((s[i] = -1), (o &= ~u)); } } function Ek(s) { if (6 & Bs) throw Error(p(327)); Hk(); var o = uc(s, 0); - if (!(1 & o)) return Dk(s, dt()), null; + if (!(1 & o)) return (Dk(s, dt()), null); var i = Ik(s, o); if (0 !== s.tag && 2 === i) { var u = xc(s); @@ -21969,14 +21999,14 @@ try { if (((Ls.transition = null), (At = 1), s)) return s(); } finally { - (At = u), (Ls.transition = i), !(6 & (Bs = o)) && jg(); + ((At = u), (Ls.transition = i), !(6 & (Bs = o)) && jg()); } } function Hj() { - (Vs = Us.current), E(Us); + ((Vs = Us.current), E(Us)); } function Kk(s, o) { - (s.finishedWork = null), (s.finishedLanes = 0); + ((s.finishedWork = null), (s.finishedLanes = 0)); var i = s.timeoutHandle; if ((-1 !== i && ((s.timeoutHandle = -1), cn(i)), null !== qs)) for (i = qs.return; null !== i; ) { @@ -21986,7 +22016,7 @@ null != (u = u.type.childContextTypes) && $f(); break; case 3: - zh(), E(Sn), E(wn), Eh(); + (zh(), E(Sn), E(wn), Eh()); break; case 5: Bh(u); @@ -22024,7 +22054,7 @@ w = i.pending; if (null !== w) { var x = w.next; - (w.next = _), (u.next = x); + ((w.next = _), (u.next = x)); } i.pending = u; } @@ -22039,7 +22069,7 @@ if (($g(), (rs.current = ds), cs)) { for (var u = os.memoizedState; null !== u; ) { var _ = u.queue; - null !== _ && (_.pending = null), (u = u.next); + (null !== _ && (_.pending = null), (u = u.next)); } cs = !1; } @@ -22051,7 +22081,7 @@ (Ds.current = null), null === i || null === i.return) ) { - (zs = 1), (Ws = o), (qs = null); + ((zs = 1), (Ws = o), (qs = null)); break; } e: { @@ -22077,34 +22107,34 @@ } var U = Ui(x); if (null !== U) { - (U.flags &= -257), Vi(U, x, C, 0, o), 1 & U.mode && Si(w, L, o), (j = L); + ((U.flags &= -257), Vi(U, x, C, 0, o), 1 & U.mode && Si(w, L, o), (j = L)); var z = (o = U).updateQueue; if (null === z) { var Y = new Set(); - Y.add(j), (o.updateQueue = Y); + (Y.add(j), (o.updateQueue = Y)); } else z.add(j); break e; } if (!(1 & o)) { - Si(w, L, o), tj(); + (Si(w, L, o), tj()); break e; } j = Error(p(426)); } else if (Fn && 1 & C.mode) { var Z = Ui(x); if (null !== Z) { - !(65536 & Z.flags) && (Z.flags |= 256), Vi(Z, x, C, 0, o), Jg(Ji(j, C)); + (!(65536 & Z.flags) && (Z.flags |= 256), Vi(Z, x, C, 0, o), Jg(Ji(j, C))); break e; } } - (w = j = Ji(j, C)), + ((w = j = Ji(j, C)), 4 !== zs && (zs = 2), null === Gs ? (Gs = [w]) : Gs.push(w), - (w = x); + (w = x)); do { switch (w.tag) { case 3: - (w.flags |= 65536), (o &= -o), (w.lanes |= o), ph(w, Ni(0, j, o)); + ((w.flags |= 65536), (o &= -o), (w.lanes |= o), ph(w, Ni(0, j, o))); break e; case 1: C = j; @@ -22119,7 +22149,7 @@ (null !== ro && ro.has(ie)))) ) ) { - (w.flags |= 65536), (o &= -o), (w.lanes |= o), ph(w, Qi(w, C, o)); + ((w.flags |= 65536), (o &= -o), (w.lanes |= o), ph(w, Qi(w, C, o))); break e; } } @@ -22128,7 +22158,7 @@ } Sk(i); } catch (s) { - (o = s), qs === i && null !== i && (qs = i = i.return); + ((o = s), qs === i && null !== i && (qs = i = i.return)); continue; } break; @@ -22136,11 +22166,11 @@ } function Jk() { var s = Rs.current; - return (Rs.current = ds), null === s ? ds : s; + return ((Rs.current = ds), null === s ? ds : s); } function tj() { - (0 !== zs && 3 !== zs && 2 !== zs) || (zs = 4), - null === Fs || (!(268435455 & Ks) && !(268435455 & Hs)) || Ck(Fs, $s); + ((0 !== zs && 3 !== zs && 2 !== zs) || (zs = 4), + null === Fs || (!(268435455 & Ks) && !(268435455 & Hs)) || Ck(Fs, $s)); } function Ik(s, o) { var i = Bs; @@ -22154,7 +22184,7 @@ Mk(s, o); } if (($g(), (Bs = i), (Rs.current = u), null !== qs)) throw Error(p(261)); - return (Fs = null), ($s = 0), zs; + return ((Fs = null), ($s = 0), zs); } function Tk() { for (; null !== qs; ) Uk(qs); @@ -22164,16 +22194,18 @@ } function Uk(s) { var o = Ts(s.alternate, s, Vs); - (s.memoizedProps = s.pendingProps), null === o ? Sk(s) : (qs = o), (Ds.current = null); + ((s.memoizedProps = s.pendingProps), + null === o ? Sk(s) : (qs = o), + (Ds.current = null)); } function Sk(s) { var o = s; do { var i = o.alternate; if (((s = o.return), 32768 & o.flags)) { - if (null !== (i = Ij(i, o))) return (i.flags &= 32767), void (qs = i); - if (null === s) return (zs = 6), void (qs = null); - (s.flags |= 32768), (s.subtreeFlags = 0), (s.deletions = null); + if (null !== (i = Ij(i, o))) return ((i.flags &= 32767), void (qs = i)); + if (null === s) return ((zs = 6), void (qs = null)); + ((s.flags |= 32768), (s.subtreeFlags = 0), (s.deletions = null)); } else if (null !== (i = Ej(i, o, Vs))) return void (qs = i); if (null !== (o = o.sibling)) return void (qs = o); qs = o = s; @@ -22184,7 +22216,7 @@ var u = At, _ = Ls.transition; try { - (Ls.transition = null), + ((Ls.transition = null), (At = 1), (function Wk(s, o, i, u) { do { @@ -22196,23 +22228,23 @@ if (null === i) return null; if (((s.finishedWork = null), (s.finishedLanes = 0), i === s.current)) throw Error(p(177)); - (s.callbackNode = null), (s.callbackPriority = 0); + ((s.callbackNode = null), (s.callbackPriority = 0)); var w = i.lanes | i.childLanes; if ( ((function Bc(s, o) { var i = s.pendingLanes & ~o; - (s.pendingLanes = o), + ((s.pendingLanes = o), (s.suspendedLanes = 0), (s.pingedLanes = 0), (s.expiredLanes &= o), (s.mutableReadLanes &= o), (s.entangledLanes &= o), - (o = s.entanglements); + (o = s.entanglements)); var u = s.eventTimes; for (s = s.expirationTimes; 0 < i; ) { var _ = 31 - St(i), w = 1 << _; - (o[_] = 0), (u[_] = -1), (s[_] = -1), (i &= ~w); + ((o[_] = 0), (u[_] = -1), (s[_] = -1), (i &= ~w)); } })(s, w), s === Fs && ((qs = Fs = null), ($s = 0)), @@ -22220,16 +22252,16 @@ no || ((no = !0), Fk(vt, function () { - return Hk(), null; + return (Hk(), null); })), (w = !!(15990 & i.flags)), !!(15990 & i.subtreeFlags) || w) ) { - (w = Ls.transition), (Ls.transition = null); + ((w = Ls.transition), (Ls.transition = null)); var x = At; At = 1; var C = Bs; - (Bs |= 4), + ((Bs |= 4), (Ds.current = null), (function Oj(s, o) { if (((on = zt), Ne((s = Me())))) { @@ -22246,7 +22278,7 @@ w = u.focusNode; u = u.focusOffset; try { - i.nodeType, w.nodeType; + (i.nodeType, w.nodeType); } catch (s) { i = null; break e; @@ -22265,9 +22297,8 @@ $ !== w || (0 !== u && 3 !== $.nodeType) || (j = x + u), 3 === $.nodeType && (x += $.nodeValue.length), null !== (U = $.firstChild); - ) - (V = $), ($ = U); + ((V = $), ($ = U)); for (;;) { if ($ === s) break t; if ( @@ -22288,10 +22319,9 @@ for ( an = { focusedElem: s, selectionRange: i }, zt = !1, js = o; null !== js; - ) if (((s = (o = js).child), 1028 & o.subtreeFlags && null !== s)) - (s.return = o), (js = s); + ((s.return = o), (js = s)); else for (; null !== js; ) { o = js; @@ -22334,12 +22364,12 @@ W(o, o.return, s); } if (null !== (s = o.sibling)) { - (s.return = o.return), (js = s); + ((s.return = o.return), (js = s)); break; } js = o.return; } - return (z = Is), (Is = !1), z; + return ((z = Is), (Is = !1), z); })(s, i), dk(i, s), Oe(an), @@ -22350,7 +22380,7 @@ ht(), (Bs = C), (At = x), - (Ls.transition = w); + (Ls.transition = w)); } else s.current = i; if ( (no && ((no = !1), (so = s), (oo = _)), @@ -22366,7 +22396,7 @@ null !== o) ) for (u = s.onRecoverableError, i = 0; i < o.length; i++) - (_ = o[i]), u(_.value, { componentStack: _.stack, digest: _.digest }); + ((_ = o[i]), u(_.value, { componentStack: _.stack, digest: _.digest })); if (eo) throw ((eo = !1), (s = to), (to = null), s); return ( !!(1 & oo) && 0 !== s.tag && Hk(), @@ -22375,9 +22405,9 @@ jg(), null ); - })(s, o, i, u); + })(s, o, i, u)); } finally { - (Ls.transition = _), (At = u); + ((Ls.transition = _), (At = u)); } return null; } @@ -22408,7 +22438,7 @@ Pj(8, B, w); } var $ = B.child; - if (null !== $) ($.return = B), (js = $); + if (null !== $) (($.return = B), (js = $)); else for (; null !== js; ) { var V = (B = js).sibling, @@ -22418,7 +22448,7 @@ break; } if (null !== V) { - (V.return = U), (js = V); + ((V.return = U), (js = V)); break; } js = U; @@ -22432,14 +22462,14 @@ z.child = null; do { var Z = Y.sibling; - (Y.sibling = null), (Y = Z); + ((Y.sibling = null), (Y = Z)); } while (null !== Y); } } js = w; } } - if (2064 & w.subtreeFlags && null !== x) (x.return = w), (js = x); + if (2064 & w.subtreeFlags && null !== x) ((x.return = w), (js = x)); else e: for (; null !== js; ) { if (2048 & (w = js).flags) @@ -22451,7 +22481,7 @@ } var ee = w.sibling; if (null !== ee) { - (ee.return = w.return), (js = ee); + ((ee.return = w.return), (js = ee)); break e; } js = w.return; @@ -22460,7 +22490,7 @@ var ie = s.current; for (js = ie; null !== js; ) { var ae = (x = js).child; - if (2064 & x.subtreeFlags && null !== ae) (ae.return = x), (js = ae); + if (2064 & x.subtreeFlags && null !== ae) ((ae.return = x), (js = ae)); else e: for (x = ie; null !== js; ) { if (2048 & (C = js).flags) @@ -22480,7 +22510,7 @@ } var le = C.sibling; if (null !== le) { - (le.return = C.return), (js = le); + ((le.return = C.return), (js = le)); break e; } js = C.return; @@ -22494,15 +22524,15 @@ } return u; } finally { - (At = i), (Ls.transition = o); + ((At = i), (Ls.transition = o)); } } return !1; } function Xk(s, o, i) { - (s = nh(s, (o = Ni(0, (o = Ji(i, o)), 1)), 1)), + ((s = nh(s, (o = Ni(0, (o = Ji(i, o)), 1)), 1)), (o = R()), - null !== s && (Ac(s, 1, o), Dk(s, o)); + null !== s && (Ac(s, 1, o), Dk(s, o))); } function W(s, o, i) { if (3 === s.tag) Xk(s, s, i); @@ -22518,9 +22548,9 @@ 'function' == typeof o.type.getDerivedStateFromError || ('function' == typeof u.componentDidCatch && (null === ro || !ro.has(u))) ) { - (o = nh(o, (s = Qi(o, (s = Ji(i, s)), 1)), 1)), + ((o = nh(o, (s = Qi(o, (s = Ji(i, s)), 1)), 1)), (s = R()), - null !== o && (Ac(o, 1, s), Dk(o, s)); + null !== o && (Ac(o, 1, s), Dk(o, s))); break; } } @@ -22529,7 +22559,7 @@ } function Ti(s, o, i) { var u = s.pingCache; - null !== u && u.delete(o), + (null !== u && u.delete(o), (o = R()), (s.pingedLanes |= s.suspendedLanes & i), Fs === s && @@ -22537,7 +22567,7 @@ (4 === zs || (3 === zs && (130023424 & $s) === $s && 500 > dt() - Xs) ? Kk(s, 0) : (Js |= i)), - Dk(s, o); + Dk(s, o)); } function Yk(s, o) { 0 === o && @@ -22548,7 +22578,7 @@ function uj(s) { var o = s.memoizedState, i = 0; - null !== o && (i = o.retryLane), Yk(s, i); + (null !== o && (i = o.retryLane), Yk(s, i)); } function bk(s, o) { var i = 0; @@ -22564,13 +22594,13 @@ default: throw Error(p(314)); } - null !== u && u.delete(o), Yk(s, i); + (null !== u && u.delete(o), Yk(s, i)); } function Fk(s, o) { return ct(s, o); } function $k(s, o, i, u) { - (this.tag = s), + ((this.tag = s), (this.key = i), (this.sibling = this.child = @@ -22591,7 +22621,7 @@ (this.subtreeFlags = this.flags = 0), (this.deletions = null), (this.childLanes = this.lanes = 0), - (this.alternate = null); + (this.alternate = null)); } function Bg(s, o, i, u) { return new $k(s, o, i, u); @@ -22638,14 +22668,14 @@ case ee: return Tg(i.children, _, w, o); case ie: - (x = 8), (_ |= 8); + ((x = 8), (_ |= 8)); break; case ae: - return ((s = Bg(12, i, o, 2 | _)).elementType = ae), (s.lanes = w), s; + return (((s = Bg(12, i, o, 2 | _)).elementType = ae), (s.lanes = w), s); case de: - return ((s = Bg(13, i, o, _)).elementType = de), (s.lanes = w), s; + return (((s = Bg(13, i, o, _)).elementType = de), (s.lanes = w), s); case fe: - return ((s = Bg(19, i, o, _)).elementType = fe), (s.lanes = w), s; + return (((s = Bg(19, i, o, _)).elementType = fe), (s.lanes = w), s); case _e: return pj(i, _, w, o); default: @@ -22664,15 +22694,15 @@ x = 14; break e; case be: - (x = 16), (u = null); + ((x = 16), (u = null)); break e; } throw Error(p(130, null == s ? s : typeof s, '')); } - return ((o = Bg(x, i, o, _)).elementType = s), (o.type = u), (o.lanes = w), o; + return (((o = Bg(x, i, o, _)).elementType = s), (o.type = u), (o.lanes = w), o); } function Tg(s, o, i, u) { - return ((s = Bg(7, s, u, o)).lanes = i), s; + return (((s = Bg(7, s, u, o)).lanes = i), s); } function pj(s, o, i, u) { return ( @@ -22683,7 +22713,7 @@ ); } function Qg(s, o, i) { - return ((s = Bg(6, s, null, o)).lanes = i), s; + return (((s = Bg(6, s, null, o)).lanes = i), s); } function Sg(s, o, i) { return ( @@ -22697,7 +22727,7 @@ ); } function al(s, o, i, u, _) { - (this.tag = o), + ((this.tag = o), (this.containerInfo = s), (this.finishedWork = this.pingCache = this.current = this.pendingChildren = null), (this.timeoutHandle = -1), @@ -22716,7 +22746,7 @@ (this.entanglements = zc(0)), (this.identifierPrefix = u), (this.onRecoverableError = _), - (this.mutableSourceEagerHydrationData = null); + (this.mutableSourceEagerHydrationData = null)); } function bl(s, o, i, u, _, w, x, C, j) { return ( @@ -22797,7 +22827,7 @@ } } function il(s, o) { - hl(s, o), (s = s.alternate) && hl(s, o); + (hl(s, o), (s = s.alternate) && hl(s, o)); } Ts = function (s, o, i) { if (null !== s) @@ -22809,7 +22839,7 @@ (function yj(s, o, i) { switch (o.tag) { case 3: - kj(o), Ig(); + (kj(o), Ig()); break; case 5: Ah(o); @@ -22823,7 +22853,7 @@ case 10: var u = o.type._context, _ = o.memoizedProps.value; - G(zn, u._currentValue), (u._currentValue = _); + (G(zn, u._currentValue), (u._currentValue = _)); break; case 13: if (null !== (u = o.memoizedState)) @@ -22850,20 +22880,20 @@ return null; case 22: case 23: - return (o.lanes = 0), dj(s, o, i); + return ((o.lanes = 0), dj(s, o, i)); } return Zi(s, o, i); })(s, o, i) ); _s = !!(131072 & s.flags); } - else (_s = !1), Fn && 1048576 & o.flags && ug(o, Pn, o.index); + else ((_s = !1), Fn && 1048576 & o.flags && ug(o, Pn, o.index)); switch (((o.lanes = 0), o.tag)) { case 2: var u = o.type; - ij(s, o), (s = o.pendingProps); + (ij(s, o), (s = o.pendingProps)); var _ = Yf(o, wn.current); - ch(o, i), (_ = Nh(null, o, u, s, _, i)); + (ch(o, i), (_ = Nh(null, o, u, s, _, i))); var w = Sh(); return ( (o.flags |= 1), @@ -22936,10 +22966,10 @@ case 3: e: { if ((kj(o), null === s)) throw Error(p(387)); - (u = o.pendingProps), + ((u = o.pendingProps), (_ = (w = o.memoizedState).element), lh(s, o), - qh(o, u, null, i); + qh(o, u, null, i)); var x = o.memoizedState; if (((u = x.element), w.isDehydrated)) { if ( @@ -22969,9 +22999,8 @@ i = Un(o, null, u, i), o.child = i; i; - ) - (i.flags = (-3 & i.flags) | 4096), (i = i.sibling); + ((i.flags = (-3 & i.flags) | 4096), (i = i.sibling)); } else { if ((Ig(), u === _)) { o = Zi(s, o, i); @@ -22996,7 +23025,7 @@ o.child ); case 6: - return null === s && Eg(o), null; + return (null === s && Eg(o), null); case 13: return oj(s, o, i); case 4: @@ -23013,10 +23042,10 @@ Yi(s, o, u, (_ = o.elementType === u ? _ : Ci(u, _)), i) ); case 7: - return Xi(s, o, o.pendingProps, i), o.child; + return (Xi(s, o, o.pendingProps, i), o.child); case 8: case 12: - return Xi(s, o, o.pendingProps.children, i), o.child; + return (Xi(s, o, o.pendingProps.children, i), o.child); case 10: e: { if ( @@ -23045,14 +23074,14 @@ var L = w.updateQueue; if (null !== L) { var B = (L = L.shared).pending; - null === B ? (j.next = j) : ((j.next = B.next), (B.next = j)), - (L.pending = j); + (null === B ? (j.next = j) : ((j.next = B.next), (B.next = j)), + (L.pending = j)); } } - (w.lanes |= i), + ((w.lanes |= i), null !== (j = w.alternate) && (j.lanes |= i), bh(w.return, i, o), - (C.lanes |= i); + (C.lanes |= i)); break; } j = j.next; @@ -23060,10 +23089,10 @@ } else if (10 === w.tag) x = w.type === o.type ? null : w.child; else if (18 === w.tag) { if (null === (x = w.return)) throw Error(p(341)); - (x.lanes |= i), + ((x.lanes |= i), null !== (C = x.alternate) && (C.lanes |= i), bh(x, i, o), - (x = w.sibling); + (x = w.sibling)); } else x = w.child; if (null !== x) x.return = w; else @@ -23073,14 +23102,14 @@ break; } if (null !== (w = x.sibling)) { - (w.return = x.return), (x = w); + ((w.return = x.return), (x = w)); break; } x = x.return; } w = x; } - Xi(s, o, _.children, i), (o = o.child); + (Xi(s, o, _.children, i), (o = o.child)); } return o; case 9: @@ -23094,7 +23123,10 @@ o.child ); case 14: - return (_ = Ci((u = o.type), o.pendingProps)), $i(s, o, u, (_ = Ci(u.type, _)), i); + return ( + (_ = Ci((u = o.type), o.pendingProps)), + $i(s, o, u, (_ = Ci(u.type, _)), i) + ); case 15: return bj(s, o, o.type, o.pendingProps, i); case 17: @@ -23194,7 +23226,7 @@ })(i, o, s, _, u); return gl(x); } - (ml.prototype.render = ll.prototype.render = + ((ml.prototype.render = ll.prototype.render = function (s) { var o = this._internalRoot; if (null === o) throw Error(p(409)); @@ -23206,10 +23238,10 @@ if (null !== s) { this._internalRoot = null; var o = s.containerInfo; - Rk(function () { + (Rk(function () { fl(null, s, null, null); }), - (o[mn] = null); + (o[mn] = null)); } }), (ml.prototype.unstable_scheduleHydration = function (s) { @@ -23217,7 +23249,7 @@ var o = Mt(); s = { blockedOn: null, target: s, priority: o }; for (var i = 0; i < $t.length && 0 !== o && o < $t[i].priority; i++); - $t.splice(i, 0, s), 0 === i && Vc(s); + ($t.splice(i, 0, s), 0 === i && Vc(s)); } }), (jt = function (s) { @@ -23230,14 +23262,14 @@ } break; case 13: - Rk(function () { + (Rk(function () { var o = ih(s, 1); if (null !== o) { var i = R(); gi(o, s, 1, i); } }), - il(s, 1); + il(s, 1)); } }), (It = function (s) { @@ -23261,7 +23293,7 @@ (Tt = function (s, o) { var i = At; try { - return (At = s), o(); + return ((At = s), o()); } finally { At = i; } @@ -23283,7 +23315,7 @@ if (u !== s && u.form === s.form) { var _ = Db(u); if (!_) throw Error(p(90)); - Wa(u), bb(u, _); + (Wa(u), bb(u, _)); } } } @@ -23296,7 +23328,7 @@ } }), (Gb = Qk), - (Hb = Rk); + (Hb = Rk)); var po = { usingClientEntryPoint: !1, Events: [Cb, ue, Db, Eb, Fb, Qk] }, ho = { findFiberByHostInstance: Wc, @@ -23338,10 +23370,10 @@ var mo = __REACT_DEVTOOLS_GLOBAL_HOOK__; if (!mo.isDisabled && mo.supportsFiber) try { - (Et = mo.inject(fo)), (wt = mo); + ((Et = mo.inject(fo)), (wt = mo)); } catch (qe) {} } - (o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = po), + ((o.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = po), (o.createPortal = function (s, o) { var i = 2 < arguments.length && void 0 !== arguments[2] ? arguments[2] : null; if (!nl(o)) throw Error(p(200)); @@ -23406,10 +23438,10 @@ u) ) for (s = 0; s < u.length; s++) - (_ = (_ = (i = u[s])._getVersion)(i._source)), + ((_ = (_ = (i = u[s])._getVersion)(i._source)), null == o.mutableSourceEagerHydrationData ? (o.mutableSourceEagerHydrationData = [i, _]) - : o.mutableSourceEagerHydrationData.push(i, _); + : o.mutableSourceEagerHydrationData.push(i, _)); return new ml(o); }), (o.render = function (s, o, i) { @@ -23422,7 +23454,7 @@ !!s._reactRootContainer && (Rk(function () { rl(null, null, s, !1, function () { - (s._reactRootContainer = null), (s[mn] = null); + ((s._reactRootContainer = null), (s[mn] = null)); }); }), !0) @@ -23434,11 +23466,11 @@ if (null == s || void 0 === s._reactInternals) throw Error(p(38)); return rl(s, o, i, !1, u); }), - (o.version = '18.3.1-next-f1338f8080-20240426'); + (o.version = '18.3.1-next-f1338f8080-20240426')); }, 40961: (s, o, i) => { 'use strict'; - !(function checkDCE() { + (!(function checkDCE() { if ( 'undefined' != typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && 'function' == typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE @@ -23449,7 +23481,7 @@ console.error(s); } })(), - (s.exports = i(22551)); + (s.exports = i(22551))); }, 2209: (s, o, i) => { 'use strict'; @@ -23487,7 +23519,7 @@ ); } var o = checkType.bind(null, !1); - return (o.isRequired = checkType.bind(null, !0)), o; + return ((o.isRequired = checkType.bind(null, !0)), o); } function createIterableSubclassTypeChecker(s, o) { return (function createImmutableTypeChecker(s, o) { @@ -23515,7 +23547,7 @@ return _.Iterable.isIterable(s) && o(s); }); } - ((u = { + (((u = { listOf: x, mapOf: x, orderedMapOf: x, @@ -23539,7 +23571,7 @@ iterable: w }).iterable.indexed = createIterableSubclassTypeChecker('Indexed', _.Iterable.isIndexed)), (u.iterable.keyed = createIterableSubclassTypeChecker('Keyed', _.Iterable.isKeyed)), - (s.exports = u); + (s.exports = u)); }, 15287: (s, o) => { 'use strict'; @@ -23566,13 +23598,13 @@ Y = Object.assign, Z = {}; function E(s, o, i) { - (this.props = s), (this.context = o), (this.refs = Z), (this.updater = i || z); + ((this.props = s), (this.context = o), (this.refs = Z), (this.updater = i || z)); } function F() {} function G(s, o, i) { - (this.props = s), (this.context = o), (this.refs = Z), (this.updater = i || z); + ((this.props = s), (this.context = o), (this.refs = Z), (this.updater = i || z)); } - (E.prototype.isReactComponent = {}), + ((E.prototype.isReactComponent = {}), (E.prototype.setState = function (s, o) { if ('object' != typeof s && 'function' != typeof s && null != s) throw Error( @@ -23583,9 +23615,9 @@ (E.prototype.forceUpdate = function (s) { this.updater.enqueueForceUpdate(this, s, 'forceUpdate'); }), - (F.prototype = E.prototype); + (F.prototype = E.prototype)); var ee = (G.prototype = new F()); - (ee.constructor = G), Y(ee, E.prototype), (ee.isPureReactComponent = !0); + ((ee.constructor = G), Y(ee, E.prototype), (ee.isPureReactComponent = !0)); var ie = Array.isArray, ae = Object.prototype.hasOwnProperty, le = { current: null }, @@ -23694,14 +23726,14 @@ j += R((C = C.value), o, _, (B = w + Q(C, L++)), x); else if ('object' === C) throw ( - ((o = String(s)), + (o = String(s)), Error( 'Objects are not valid as a React child (found: ' + ('[object Object]' === o ? 'object with keys {' + Object.keys(s).join(', ') + '}' : o) + '). If you meant to render a collection of children, use an array instead.' - )) + ) ); return j; } @@ -23719,7 +23751,7 @@ function T(s) { if (-1 === s._status) { var o = s._result; - (o = o()).then( + ((o = o()).then( function (o) { (0 !== s._status && -1 !== s._status) || ((s._status = 1), (s._result = o)); }, @@ -23727,7 +23759,7 @@ (0 !== s._status && -1 !== s._status) || ((s._status = 2), (s._result = o)); } ), - -1 === s._status && ((s._status = 0), (s._result = o)); + -1 === s._status && ((s._status = 0), (s._result = o))); } if (1 === s._status) return s._result.default; throw s._result; @@ -23738,7 +23770,7 @@ function X() { throw Error('act(...) is not supported in production builds of React.'); } - (o.Children = { + ((o.Children = { map: S, forEach: function (s, o, i) { S( @@ -23831,7 +23863,7 @@ (o.createElement = M), (o.createFactory = function (s) { var o = M.bind(null, s); - return (o.type = s), o; + return ((o.type = s), o); }), (o.createRef = function () { return { current: null }; @@ -23899,7 +23931,7 @@ (o.useTransition = function () { return de.current.useTransition(); }), - (o.version = '18.3.1'); + (o.version = '18.3.1')); }, 96540: (s, o, i) => { 'use strict'; @@ -23923,14 +23955,14 @@ } return ( (function _inheritsLoose(s, o) { - (s.prototype = Object.create(o.prototype)), + ((s.prototype = Object.create(o.prototype)), (s.prototype.constructor = s), - (s.__proto__ = o); + (s.__proto__ = o)); })(NodeError, s), NodeError ); })(u); - (_.prototype.name = u.name), (_.prototype.code = s), (o[s] = _); + ((_.prototype.name = u.name), (_.prototype.code = s), (o[s] = _)); } function oneOf(s, o) { if (Array.isArray(s)) { @@ -23949,7 +23981,7 @@ } return 'of '.concat(o, ' ').concat(String(s)); } - createErrorType( + (createErrorType( 'ERR_INVALID_OPT_VALUE', function (s, o) { return 'The value "' + o + '" is invalid for option "' + s + '"'; @@ -24021,7 +24053,7 @@ 'ERR_STREAM_UNSHIFT_AFTER_END_EVENT', 'stream.unshift() after end event' ), - (s.exports.F = o); + (s.exports.F = o)); }, 25382: (s, o, i) => { 'use strict'; @@ -24043,13 +24075,13 @@ } function Duplex(s) { if (!(this instanceof Duplex)) return new Duplex(s); - w.call(this, s), + (w.call(this, s), x.call(this, s), (this.allowHalfOpen = !0), s && (!1 === s.readable && (this.readable = !1), !1 === s.writable && (this.writable = !1), - !1 === s.allowHalfOpen && ((this.allowHalfOpen = !1), this.once('end', onend))); + !1 === s.allowHalfOpen && ((this.allowHalfOpen = !1), this.once('end', onend)))); } function onend() { this._writableState.ended || u.nextTick(onEndNT, this); @@ -24057,7 +24089,7 @@ function onEndNT(s) { s.end(); } - Object.defineProperty(Duplex.prototype, 'writableHighWaterMark', { + (Object.defineProperty(Duplex.prototype, 'writableHighWaterMark', { enumerable: !1, get: function get() { return this._writableState.highWaterMark; @@ -24090,7 +24122,7 @@ void 0 !== this._writableState && ((this._readableState.destroyed = s), (this._writableState.destroyed = s)); } - }); + })); }, 63600: (s, o, i) => { 'use strict'; @@ -24100,16 +24132,16 @@ if (!(this instanceof PassThrough)) return new PassThrough(s); u.call(this, s); } - i(56698)(PassThrough, u), + (i(56698)(PassThrough, u), (PassThrough.prototype._transform = function (s, o, i) { i(null, s); - }); + })); }, 45412: (s, o, i) => { 'use strict'; var u, _ = i(65606); - (s.exports = Readable), (Readable.ReadableState = ReadableState); + ((s.exports = Readable), (Readable.ReadableState = ReadableState)); i(37007).EventEmitter; var w = function EElistenerCount(s, o) { return s.listeners(o).length; @@ -24143,7 +24175,7 @@ var pe = Y.errorOrDestroy, de = ['error', 'close', 'destroy', 'pause', 'resume']; function ReadableState(s, o, _) { - (u = u || i(25382)), + ((u = u || i(25382)), (s = s || {}), 'boolean' != typeof _ && (_ = o instanceof u), (this.objectMode = !!s.objectMode), @@ -24174,36 +24206,36 @@ s.encoding && ($ || ($ = i(83141).I), (this.decoder = new $(s.encoding)), - (this.encoding = s.encoding)); + (this.encoding = s.encoding))); } function Readable(s) { if (((u = u || i(25382)), !(this instanceof Readable))) return new Readable(s); var o = this instanceof u; - (this._readableState = new ReadableState(s, this, o)), + ((this._readableState = new ReadableState(s, this, o)), (this.readable = !0), s && ('function' == typeof s.read && (this._read = s.read), 'function' == typeof s.destroy && (this._destroy = s.destroy)), - x.call(this); + x.call(this)); } function readableAddChunk(s, o, i, u, _) { L('readableAddChunk', o); var w, x = s._readableState; if (null === o) - (x.reading = !1), + ((x.reading = !1), (function onEofChunk(s, o) { if ((L('onEofChunk'), o.ended)) return; if (o.decoder) { var i = o.decoder.end(); i && i.length && (o.buffer.push(i), (o.length += o.objectMode ? 1 : i.length)); } - (o.ended = !0), + ((o.ended = !0), o.sync ? emitReadable(s) : ((o.needReadable = !1), - o.emittedReadable || ((o.emittedReadable = !0), emitReadable_(s))); - })(s, x); + o.emittedReadable || ((o.emittedReadable = !0), emitReadable_(s)))); + })(s, x)); else if ( (_ || (w = (function chunkInvalid(s, o) { @@ -24234,24 +24266,24 @@ else if (x.ended) pe(s, new ae()); else { if (x.destroyed) return !1; - (x.reading = !1), + ((x.reading = !1), x.decoder && !i ? ((o = x.decoder.write(o)), x.objectMode || 0 !== o.length ? addChunk(s, x, o, !1) : maybeReadMore(s, x)) - : addChunk(s, x, o, !1); + : addChunk(s, x, o, !1)); } else u || ((x.reading = !1), maybeReadMore(s, x)); return !x.ended && (x.length < x.highWaterMark || 0 === x.length); } function addChunk(s, o, i, u) { - o.flowing && 0 === o.length && !o.sync + (o.flowing && 0 === o.length && !o.sync ? ((o.awaitDrain = 0), s.emit('data', i)) : ((o.length += o.objectMode ? 1 : i.length), u ? o.buffer.unshift(i) : o.buffer.push(i), o.needReadable && emitReadable(s)), - maybeReadMore(s, o); + maybeReadMore(s, o)); } - Object.defineProperty(Readable.prototype, 'destroyed', { + (Object.defineProperty(Readable.prototype, 'destroyed', { enumerable: !1, get: function get() { return void 0 !== this._readableState && this._readableState.destroyed; @@ -24286,17 +24318,17 @@ (Readable.prototype.setEncoding = function (s) { $ || ($ = i(83141).I); var o = new $(s); - (this._readableState.decoder = o), - (this._readableState.encoding = this._readableState.decoder.encoding); + ((this._readableState.decoder = o), + (this._readableState.encoding = this._readableState.decoder.encoding)); for (var u = this._readableState.buffer.head, _ = ''; null !== u; ) - (_ += o.write(u.data)), (u = u.next); + ((_ += o.write(u.data)), (u = u.next)); return ( this._readableState.buffer.clear(), '' !== _ && this._readableState.buffer.push(_), (this._readableState.length = _.length), this ); - }); + })); var fe = 1073741824; function howMuchToRead(s, o) { return s <= 0 || (0 === o.length && o.ended) @@ -24326,21 +24358,21 @@ } function emitReadable(s) { var o = s._readableState; - L('emitReadable', o.needReadable, o.emittedReadable), + (L('emitReadable', o.needReadable, o.emittedReadable), (o.needReadable = !1), o.emittedReadable || (L('emitReadable', o.flowing), (o.emittedReadable = !0), - _.nextTick(emitReadable_, s)); + _.nextTick(emitReadable_, s))); } function emitReadable_(s) { var o = s._readableState; - L('emitReadable_', o.destroyed, o.length, o.ended), + (L('emitReadable_', o.destroyed, o.length, o.ended), o.destroyed || (!o.length && !o.ended) || (s.emit('readable'), (o.emittedReadable = !1)), (o.needReadable = !o.flowing && !o.ended && o.length <= o.highWaterMark), - flow(s); + flow(s)); } function maybeReadMore(s, o) { o.readingMore || ((o.readingMore = !0), _.nextTick(maybeReadMore_, s, o)); @@ -24351,7 +24383,6 @@ !o.reading && !o.ended && (o.length < o.highWaterMark || (o.flowing && 0 === o.length)); - ) { var i = o.length; if ((L('maybeReadMore read 0'), s.read(0), i === o.length)) break; @@ -24360,21 +24391,21 @@ } function updateReadableListening(s) { var o = s._readableState; - (o.readableListening = s.listenerCount('readable') > 0), + ((o.readableListening = s.listenerCount('readable') > 0), o.resumeScheduled && !o.paused ? (o.flowing = !0) - : s.listenerCount('data') > 0 && s.resume(); + : s.listenerCount('data') > 0 && s.resume()); } function nReadingNextTick(s) { - L('readable nexttick read 0'), s.read(0); + (L('readable nexttick read 0'), s.read(0)); } function resume_(s, o) { - L('resume', o.reading), + (L('resume', o.reading), o.reading || s.read(0), (o.resumeScheduled = !1), s.emit('resume'), flow(s), - o.flowing && !o.reading && s.read(0); + o.flowing && !o.reading && s.read(0)); } function flow(s) { var o = s._readableState; @@ -24398,8 +24429,8 @@ } function endReadable(s) { var o = s._readableState; - L('endReadable', o.endEmitted), - o.endEmitted || ((o.ended = !0), _.nextTick(endReadableNT, o, s)); + (L('endReadable', o.endEmitted), + o.endEmitted || ((o.ended = !0), _.nextTick(endReadableNT, o, s))); } function endReadableNT(s, o) { if ( @@ -24416,8 +24447,8 @@ for (var i = 0, u = s.length; i < u; i++) if (s[i] === o) return i; return -1; } - (Readable.prototype.read = function (s) { - L('read', s), (s = parseInt(s, 10)); + ((Readable.prototype.read = function (s) { + (L('read', s), (s = parseInt(s, 10))); var o = this._readableState, i = s; if ( @@ -24432,7 +24463,7 @@ null ); if (0 === (s = howMuchToRead(s, o)) && o.ended) - return 0 === o.length && endReadable(this), null; + return (0 === o.length && endReadable(this), null); var u, _ = o.needReadable; return ( @@ -24474,16 +24505,16 @@ default: u.pipes.push(s); } - (u.pipesCount += 1), L('pipe count=%d opts=%j', u.pipesCount, o); + ((u.pipesCount += 1), L('pipe count=%d opts=%j', u.pipesCount, o)); var x = (!o || !1 !== o.end) && s !== _.stdout && s !== _.stderr ? onend : unpipe; function onunpipe(o, _) { - L('onunpipe'), + (L('onunpipe'), o === i && _ && !1 === _.hasUnpiped && ((_.hasUnpiped = !0), (function cleanup() { - L('cleanup'), + (L('cleanup'), s.removeListener('close', onclose), s.removeListener('finish', onfinish), s.removeListener('drain', C), @@ -24493,19 +24524,19 @@ i.removeListener('end', unpipe), i.removeListener('data', ondata), (j = !0), - !u.awaitDrain || (s._writableState && !s._writableState.needDrain) || C(); - })()); + !u.awaitDrain || (s._writableState && !s._writableState.needDrain) || C()); + })())); } function onend() { - L('onend'), s.end(); + (L('onend'), s.end()); } - u.endEmitted ? _.nextTick(x) : i.once('end', x), s.on('unpipe', onunpipe); + (u.endEmitted ? _.nextTick(x) : i.once('end', x), s.on('unpipe', onunpipe)); var C = (function pipeOnDrain(s) { return function pipeOnDrainFunctionResult() { var o = s._readableState; - L('pipeOnDrain', o.awaitDrain), + (L('pipeOnDrain', o.awaitDrain), o.awaitDrain && o.awaitDrain--, - 0 === o.awaitDrain && w(s, 'data') && ((o.flowing = !0), flow(s)); + 0 === o.awaitDrain && w(s, 'data') && ((o.flowing = !0), flow(s))); }; })(i); s.on('drain', C); @@ -24513,28 +24544,28 @@ function ondata(o) { L('ondata'); var _ = s.write(o); - L('dest.write', _), + (L('dest.write', _), !1 === _ && (((1 === u.pipesCount && u.pipes === s) || (u.pipesCount > 1 && -1 !== indexOf(u.pipes, s))) && !j && (L('false write response, pause', u.awaitDrain), u.awaitDrain++), - i.pause()); + i.pause())); } function onerror(o) { - L('onerror', o), + (L('onerror', o), unpipe(), s.removeListener('error', onerror), - 0 === w(s, 'error') && pe(s, o); + 0 === w(s, 'error') && pe(s, o)); } function onclose() { - s.removeListener('finish', onfinish), unpipe(); + (s.removeListener('finish', onfinish), unpipe()); } function onfinish() { - L('onfinish'), s.removeListener('close', onclose), unpipe(); + (L('onfinish'), s.removeListener('close', onclose), unpipe()); } function unpipe() { - L('unpipe'), i.unpipe(s); + (L('unpipe'), i.unpipe(s)); } return ( i.on('data', ondata), @@ -24570,7 +24601,7 @@ if (!s) { var u = o.pipes, _ = o.pipesCount; - (o.pipes = null), (o.pipesCount = 0), (o.flowing = !1); + ((o.pipes = null), (o.pipesCount = 0), (o.flowing = !1)); for (var w = 0; w < _; w++) u[w].emit('unpipe', this, { hasUnpiped: !1 }); return this; } @@ -24607,12 +24638,13 @@ (Readable.prototype.addListener = Readable.prototype.on), (Readable.prototype.removeListener = function (s, o) { var i = x.prototype.removeListener.call(this, s, o); - return 'readable' === s && _.nextTick(updateReadableListening, this), i; + return ('readable' === s && _.nextTick(updateReadableListening, this), i); }), (Readable.prototype.removeAllListeners = function (s) { var o = x.prototype.removeAllListeners.apply(this, arguments); return ( - ('readable' !== s && void 0 !== s) || _.nextTick(updateReadableListening, this), o + ('readable' !== s && void 0 !== s) || _.nextTick(updateReadableListening, this), + o ); }), (Readable.prototype.resume = function () { @@ -24665,14 +24697,14 @@ for (var w = 0; w < de.length; w++) s.on(de[w], this.emit.bind(this, de[w])); return ( (this._read = function (o) { - L('wrapped _read', o), u && ((u = !1), s.resume()); + (L('wrapped _read', o), u && ((u = !1), s.resume())); }), this ); }), 'function' == typeof Symbol && (Readable.prototype[Symbol.asyncIterator] = function () { - return void 0 === V && (V = i(2955)), V(this); + return (void 0 === V && (V = i(2955)), V(this)); }), Object.defineProperty(Readable.prototype, 'readableHighWaterMark', { enumerable: !1, @@ -24704,8 +24736,8 @@ }), 'function' == typeof Symbol && (Readable.from = function (s, o) { - return void 0 === U && (U = i(55157)), U(Readable, s, o); - }); + return (void 0 === U && (U = i(55157)), U(Readable, s, o)); + })); }, 74610: (s, o, i) => { 'use strict'; @@ -24721,14 +24753,14 @@ i.transforming = !1; var u = i.writecb; if (null === u) return this.emit('error', new w()); - (i.writechunk = null), (i.writecb = null), null != o && this.push(o), u(s); + ((i.writechunk = null), (i.writecb = null), null != o && this.push(o), u(s)); var _ = this._readableState; - (_.reading = !1), - (_.needReadable || _.length < _.highWaterMark) && this._read(_.highWaterMark); + ((_.reading = !1), + (_.needReadable || _.length < _.highWaterMark) && this._read(_.highWaterMark)); } function Transform(s) { if (!(this instanceof Transform)) return new Transform(s); - j.call(this, s), + (j.call(this, s), (this._transformState = { afterTransform: afterTransform.bind(this), needTransform: !1, @@ -24742,7 +24774,7 @@ s && ('function' == typeof s.transform && (this._transform = s.transform), 'function' == typeof s.flush && (this._flush = s.flush)), - this.on('prefinish', prefinish); + this.on('prefinish', prefinish)); } function prefinish() { var s = this; @@ -24758,9 +24790,9 @@ if (s._transformState.transforming) throw new x(); return s.push(null); } - i(56698)(Transform, j), + (i(56698)(Transform, j), (Transform.prototype.push = function (s, o) { - return (this._transformState.needTransform = !1), j.prototype.push.call(this, s, o); + return ((this._transformState.needTransform = !1), j.prototype.push.call(this, s, o)); }), (Transform.prototype._transform = function (s, o, i) { i(new _('_transform()')); @@ -24784,7 +24816,7 @@ j.prototype._destroy.call(this, s, function (s) { o(s); }); - }); + })); }, 16708: (s, o, i) => { 'use strict'; @@ -24792,7 +24824,7 @@ _ = i(65606); function CorkedRequest(s) { var o = this; - (this.next = null), + ((this.next = null), (this.entry = null), (this.finish = function () { !(function onCorkedFinish(s, o, i) { @@ -24800,13 +24832,13 @@ s.entry = null; for (; u; ) { var _ = u.callback; - o.pendingcb--, _(i), (u = u.next); + (o.pendingcb--, _(i), (u = u.next)); } o.corkedRequestsFree.next = s; })(o, s); - }); + })); } - (s.exports = Writable), (Writable.WritableState = WritableState); + ((s.exports = Writable), (Writable.WritableState = WritableState)); var w = { deprecate: i(94643) }, x = i(40345), C = i(48287).Buffer, @@ -24834,7 +24866,7 @@ ce = B.errorOrDestroy; function nop() {} function WritableState(s, o, w) { - (u = u || i(25382)), + ((u = u || i(25382)), (s = s || {}), 'boolean' != typeof w && (w = o instanceof u), (this.objectMode = !!s.objectMode), @@ -24845,9 +24877,9 @@ (this.ending = !1), (this.ended = !1), (this.finished = !1), - (this.destroyed = !1); + (this.destroyed = !1)); var x = !1 === s.decodeStrings; - (this.decodeStrings = !x), + ((this.decodeStrings = !x), (this.defaultEncoding = s.defaultEncoding || 'utf8'), (this.length = 0), (this.writing = !1), @@ -24862,15 +24894,15 @@ if ('function' != typeof w) throw new Y(); if ( ((function onwriteStateUpdate(s) { - (s.writing = !1), + ((s.writing = !1), (s.writecb = null), (s.length -= s.writelen), - (s.writelen = 0); + (s.writelen = 0)); })(i), o) ) !(function onwriteError(s, o, i, u, w) { - --o.pendingcb, + (--o.pendingcb, i ? (_.nextTick(w, u), _.nextTick(finishMaybe, s, o), @@ -24879,12 +24911,12 @@ : (w(u), (s._writableState.errorEmitted = !0), ce(s, u), - finishMaybe(s, o)); + finishMaybe(s, o))); })(s, i, u, o, w); else { var x = needFinish(i) || s.destroyed; - x || i.corked || i.bufferProcessing || !i.bufferedRequest || clearBuffer(s, i), - u ? _.nextTick(afterWrite, s, i, x, w) : afterWrite(s, i, x, w); + (x || i.corked || i.bufferProcessing || !i.bufferedRequest || clearBuffer(s, i), + u ? _.nextTick(afterWrite, s, i, x, w) : afterWrite(s, i, x, w)); } })(o, s); }), @@ -24898,22 +24930,22 @@ (this.emitClose = !1 !== s.emitClose), (this.autoDestroy = !!s.autoDestroy), (this.bufferedRequestCount = 0), - (this.corkedRequestsFree = new CorkedRequest(this)); + (this.corkedRequestsFree = new CorkedRequest(this))); } function Writable(s) { var o = this instanceof (u = u || i(25382)); if (!o && !L.call(Writable, this)) return new Writable(s); - (this._writableState = new WritableState(s, this, o)), + ((this._writableState = new WritableState(s, this, o)), (this.writable = !0), s && ('function' == typeof s.write && (this._write = s.write), 'function' == typeof s.writev && (this._writev = s.writev), 'function' == typeof s.destroy && (this._destroy = s.destroy), 'function' == typeof s.final && (this._final = s.final)), - x.call(this); + x.call(this)); } function doWrite(s, o, i, u, _, w, x) { - (o.writelen = u), + ((o.writelen = u), (o.writecb = x), (o.writing = !0), (o.sync = !0), @@ -24922,16 +24954,16 @@ : i ? s._writev(_, o.onwrite) : s._write(_, w, o.onwrite), - (o.sync = !1); + (o.sync = !1)); } function afterWrite(s, o, i, u) { - i || + (i || (function onwriteDrain(s, o) { 0 === o.length && o.needDrain && ((o.needDrain = !1), s.emit('drain')); })(s, o), o.pendingcb--, u(), - finishMaybe(s, o); + finishMaybe(s, o)); } function clearBuffer(s, o) { o.bufferProcessing = !0; @@ -24941,15 +24973,16 @@ _ = new Array(u), w = o.corkedRequestsFree; w.entry = i; - for (var x = 0, C = !0; i; ) (_[x] = i), i.isBuf || (C = !1), (i = i.next), (x += 1); - (_.allBuffers = C), + for (var x = 0, C = !0; i; ) + ((_[x] = i), i.isBuf || (C = !1), (i = i.next), (x += 1)); + ((_.allBuffers = C), doWrite(s, o, !0, o.length, _, '', w.finish), o.pendingcb++, (o.lastBufferedRequest = null), w.next ? ((o.corkedRequestsFree = w.next), (w.next = null)) : (o.corkedRequestsFree = new CorkedRequest(o)), - (o.bufferedRequestCount = 0); + (o.bufferedRequestCount = 0)); } else { for (; i; ) { var j = i.chunk, @@ -24965,7 +24998,7 @@ } null === i && (o.lastBufferedRequest = null); } - (o.bufferedRequest = i), (o.bufferProcessing = !1); + ((o.bufferedRequest = i), (o.bufferProcessing = !1)); } function needFinish(s) { return ( @@ -24974,11 +25007,11 @@ } function callFinal(s, o) { s._final(function (i) { - o.pendingcb--, + (o.pendingcb--, i && ce(s, i), (o.prefinished = !0), s.emit('prefinish'), - finishMaybe(s, o); + finishMaybe(s, o)); }); } function finishMaybe(s, o) { @@ -24999,9 +25032,9 @@ } return i; } - i(56698)(Writable, x), + (i(56698)(Writable, x), (WritableState.prototype.getBuffer = function getBuffer() { - for (var s = this.bufferedRequest, o = []; s; ) o.push(s), (s = s.next); + for (var s = this.bufferedRequest, o = []; s; ) (o.push(s), (s = s.next)); return o; }), (function () { @@ -25055,7 +25088,7 @@ u.ending ? (function writeAfterEnd(s, o) { var i = new ae(); - ce(s, i), _.nextTick(o, i); + (ce(s, i), _.nextTick(o, i)); })(this, i) : (x || (function validChunk(s, o, i, u) { @@ -25087,7 +25120,7 @@ L || (o.needDrain = !0); if (o.writing || o.corked) { var B = o.lastBufferedRequest; - (o.lastBufferedRequest = { + ((o.lastBufferedRequest = { chunk: u, encoding: _, isBuf: i, @@ -25097,7 +25130,7 @@ B ? (B.next = o.lastBufferedRequest) : (o.bufferedRequest = o.lastBufferedRequest), - (o.bufferedRequestCount += 1); + (o.bufferedRequestCount += 1)); } else doWrite(s, o, !1, j, u, _, w); return L; })(this, u, x, s, o, i))), @@ -25137,7 +25170,7 @@ )) ) throw new le(s); - return (this._writableState.defaultEncoding = s), this; + return ((this._writableState.defaultEncoding = s), this); }), Object.defineProperty(Writable.prototype, 'writableBuffer', { enumerable: !1, @@ -25165,10 +25198,10 @@ u.corked && ((u.corked = 1), this.uncork()), u.ending || (function endWritable(s, o, i) { - (o.ending = !0), + ((o.ending = !0), finishMaybe(s, o), - i && (o.finished ? _.nextTick(i) : s.once('finish', i)); - (o.ended = !0), (s.writable = !1); + i && (o.finished ? _.nextTick(i) : s.once('finish', i))); + ((o.ended = !0), (s.writable = !1)); })(this, u, i), this ); @@ -25192,7 +25225,7 @@ (Writable.prototype._undestroy = B.undestroy), (Writable.prototype._destroy = function (s, o) { o(s); - }); + })); }, 2955: (s, o, i) => { 'use strict'; @@ -25280,7 +25313,7 @@ if (null !== w) return Promise.resolve(createIterResult(w, !1)); i = new Promise(this[$]); } - return (this[B] = i), i; + return ((this[B] = i), i); } }), Symbol.asyncIterator, @@ -25330,9 +25363,9 @@ ); } var u = i[x]; - null !== u && + (null !== u && ((i[B] = null), (i[x] = null), (i[C] = null), u(createIterResult(void 0, !0))), - (i[L] = !0); + (i[L] = !0)); }), s.on('readable', onReadable.bind(null, i)), i @@ -25345,11 +25378,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -25384,10 +25417,10 @@ function _defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, _toPropertyKey(u.key), u); + Object.defineProperty(s, _toPropertyKey(u.key), u)); } } function _toPropertyKey(s) { @@ -25408,12 +25441,12 @@ w = (_ && _.custom) || 'inspect'; s.exports = (function () { function BufferList() { - !(function _classCallCheck(s, o) { + (!(function _classCallCheck(s, o) { if (!(s instanceof o)) throw new TypeError('Cannot call a class as a function'); })(this, BufferList), (this.head = null), (this.tail = null), - (this.length = 0); + (this.length = 0)); } return ( (function _createClass(s, o, i) { @@ -25428,16 +25461,16 @@ key: 'push', value: function push(s) { var o = { data: s, next: null }; - this.length > 0 ? (this.tail.next = o) : (this.head = o), + (this.length > 0 ? (this.tail.next = o) : (this.head = o), (this.tail = o), - ++this.length; + ++this.length); } }, { key: 'unshift', value: function unshift(s) { var o = { data: s, next: this.head }; - 0 === this.length && (this.tail = o), (this.head = o), ++this.length; + (0 === this.length && (this.tail = o), (this.head = o), ++this.length); } }, { @@ -25458,7 +25491,7 @@ { key: 'clear', value: function clear() { - (this.head = this.tail = null), (this.length = 0); + ((this.head = this.tail = null), (this.length = 0)); } }, { @@ -25474,12 +25507,12 @@ value: function concat(s) { if (0 === this.length) return u.alloc(0); for (var o, i, _, w = u.allocUnsafe(s >>> 0), x = this.head, C = 0; x; ) - (o = x.data), + ((o = x.data), (i = w), (_ = C), u.prototype.copy.call(o, i, _), (C += x.data.length), - (x = x.next); + (x = x.next)); return w; } }, @@ -25524,7 +25557,7 @@ } ++i; } - return (this.length -= i), u; + return ((this.length -= i), u); } }, { @@ -25544,7 +25577,7 @@ } ++_; } - return (this.length -= _), o; + return ((this.length -= _), o); } }, { @@ -25565,7 +25598,7 @@ 'use strict'; var u = i(65606); function emitErrorAndCloseNT(s, o) { - emitErrorNT(s, o), emitCloseNT(s); + (emitErrorNT(s, o), emitCloseNT(s)); } function emitCloseNT(s) { (s._writableState && !s._writableState.emitClose) || @@ -25607,7 +25640,7 @@ this); }, undestroy: function undestroy() { - this._readableState && + (this._readableState && ((this._readableState.destroyed = !1), (this._readableState.reading = !1), (this._readableState.ended = !1), @@ -25619,7 +25652,7 @@ (this._writableState.finalCalled = !1), (this._writableState.prefinished = !1), (this._writableState.finished = !1), - (this._writableState.errorEmitted = !1)); + (this._writableState.errorEmitted = !1))); }, errorOrDestroy: function errorOrDestroy(s, o) { var i = s._readableState, @@ -25634,7 +25667,7 @@ function noop() {} s.exports = function eos(s, o, i) { if ('function' == typeof o) return eos(s, null, o); - o || (o = {}), + (o || (o = {}), (i = (function once(s) { var o = !1; return function () { @@ -25645,7 +25678,7 @@ s.apply(this, u); } }; - })(i || noop)); + })(i || noop))); var _ = o.readable || (!1 !== o.readable && s.readable), w = o.writable || (!1 !== o.writable && s.writable), x = function onlegacyfinish() { @@ -25653,11 +25686,11 @@ }, C = s._writableState && s._writableState.finished, j = function onfinish() { - (w = !1), (C = !0), _ || i.call(s); + ((w = !1), (C = !0), _ || i.call(s)); }, L = s._readableState && s._readableState.endEmitted, B = function onend() { - (_ = !1), (L = !0), w || i.call(s); + ((_ = !1), (L = !0), w || i.call(s)); }, $ = function onerror(o) { i.call(s, o); @@ -25684,7 +25717,7 @@ !1 !== o.error && s.on('error', $), s.on('close', V), function () { - s.removeListener('complete', j), + (s.removeListener('complete', j), s.removeListener('abort', V), s.removeListener('request', U), s.req && s.req.removeListener('finish', j), @@ -25693,7 +25726,7 @@ s.removeListener('finish', j), s.removeListener('end', B), s.removeListener('error', $), - s.removeListener('close', V); + s.removeListener('close', V)); } ); }; @@ -25735,14 +25768,14 @@ }; })(w); var C = !1; - s.on('close', function () { + (s.on('close', function () { C = !0; }), void 0 === u && (u = i(86238)), u(s, { readable: o, writable: _ }, function (s) { if (s) return w(s); - (C = !0), w(); - }); + ((C = !0), w()); + })); var j = !1; return function (o) { if (!C && !j) @@ -25758,7 +25791,7 @@ ); }; })(s, w, _ > 0, function (s) { - C || (C = s), s && L.forEach(call), w || (L.forEach(call), j(C)); + (C || (C = s), s && L.forEach(call), w || (L.forEach(call), j(C))); }); }); return o.reduce(pipe); @@ -25791,7 +25824,7 @@ return s && s.__esModule ? s : { default: s }; })(i(9404)), _ = i(55674); - (o.default = function (s) { + ((o.default = function (s) { var o = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : u.default.Map, i = Object.keys(s); return function () { @@ -25800,12 +25833,12 @@ return u.withMutations(function (o) { i.forEach(function (i) { var u = (0, s[i])(o.get(i), w); - (0, _.validateNextState)(u, i, w), o.set(i, u); + ((0, _.validateNextState)(u, i, w), o.set(i, u)); }); }); }; }), - (s.exports = o.default); + (s.exports = o.default)); }, 89593: (s, o, i) => { 'use strict'; @@ -25817,13 +25850,13 @@ }, 48590: (s, o) => { 'use strict'; - Object.defineProperty(o, '__esModule', { value: !0 }), + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.default = function (s) { return s && '@@redux/INIT' === s.type ? 'initialState argument passed to createStore' : 'previous state received by the reducer'; }), - (s.exports = o.default); + (s.exports = o.default)); }, 82261: (s, o, i) => { 'use strict'; @@ -25833,7 +25866,7 @@ function _interopRequireDefault(s) { return s && s.__esModule ? s : { default: s }; } - (o.default = function (s, o, i) { + ((o.default = function (s, o, i) { var w = Object.keys(o); if (!w.length) return 'Store does not have a valid reducer. Make sure the argument passed to combineReducers is an object whose values are reducers.'; @@ -25867,28 +25900,28 @@ '". Unexpected properties will be ignored.' : null; }), - (s.exports = o.default); + (s.exports = o.default)); }, 55674: (s, o, i) => { 'use strict'; - Object.defineProperty(o, '__esModule', { value: !0 }), + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.validateNextState = o.getUnexpectedInvocationParameterMessage = o.getStateName = - void 0); + void 0)); var u = _interopRequireDefault(i(48590)), _ = _interopRequireDefault(i(82261)), w = _interopRequireDefault(i(27374)); function _interopRequireDefault(s) { return s && s.__esModule ? s : { default: s }; } - (o.getStateName = u.default), + ((o.getStateName = u.default), (o.getUnexpectedInvocationParameterMessage = _.default), - (o.validateNextState = w.default); + (o.validateNextState = w.default)); }, 27374: (s, o) => { 'use strict'; - Object.defineProperty(o, '__esModule', { value: !0 }), + (Object.defineProperty(o, '__esModule', { value: !0 }), (o.default = function (s, o, i) { if (void 0 === s) throw new Error( @@ -25899,7 +25932,7 @@ '" action. To ignore an action, you must explicitly return the previous state.' ); }), - (s.exports = o.default); + (s.exports = o.default)); }, 75208: (s) => { 'use strict'; @@ -25910,9 +25943,9 @@ if (1 === u) return s; if (2 === u) return s + s; var _ = s.length * u; - if (o !== s || void 0 === o) (o = s), (i = ''); + if (o !== s || void 0 === o) ((o = s), (i = '')); else if (i.length >= _) return i.substr(0, _); - for (; _ > i.length && u > 1; ) 1 & u && (i += s), (u >>= 1), (s += s); + for (; _ > i.length && u > 1; ) (1 & u && (i += s), (u >>= 1), (s += s)); return (i = (i += s).substr(0, _)); }; }, @@ -25942,7 +25975,7 @@ _ = i(6205), w = i(10023), x = i(8048); - (s.exports = (s) => { + ((s.exports = (s) => { var o, i, C = 0, @@ -25998,14 +26031,14 @@ var U; '^' === V[C] ? ((U = !0), C++) : (U = !1); var z = u.tokenizeClass(V.slice(C), s); - (C += z[1]), B.push({ type: _.SET, set: z[0], not: U }); + ((C += z[1]), B.push({ type: _.SET, set: z[0], not: U })); break; case '.': B.push(w.anyChar()); break; case '(': var Y = { type: _.GROUP, stack: [], remember: !0 }; - '?' === (i = V[C]) && + ('?' === (i = V[C]) && ((i = V[C + 1]), (C += 2), '=' === i @@ -26021,16 +26054,16 @@ B.push(Y), $.push(L), (L = Y), - (B = Y.stack); + (B = Y.stack)); break; case ')': - 0 === $.length && u.error(s, 'Unmatched ) at column ' + (C - 1)), - (B = (L = $.pop()).options ? L.options[L.options.length - 1] : L.stack); + (0 === $.length && u.error(s, 'Unmatched ) at column ' + (C - 1)), + (B = (L = $.pop()).options ? L.options[L.options.length - 1] : L.stack)); break; case '|': L.options || ((L.options = [L.stack]), delete L.stack); var Z = []; - L.options.push(Z), (B = Z); + (L.options.push(Z), (B = Z)); break; case '{': var ee, @@ -26045,30 +26078,30 @@ : B.push({ type: _.CHAR, value: 123 }); break; case '?': - 0 === B.length && repeatErr(C), - B.push({ type: _.REPETITION, min: 0, max: 1, value: B.pop() }); + (0 === B.length && repeatErr(C), + B.push({ type: _.REPETITION, min: 0, max: 1, value: B.pop() })); break; case '+': - 0 === B.length && repeatErr(C), - B.push({ type: _.REPETITION, min: 1, max: 1 / 0, value: B.pop() }); + (0 === B.length && repeatErr(C), + B.push({ type: _.REPETITION, min: 1, max: 1 / 0, value: B.pop() })); break; case '*': - 0 === B.length && repeatErr(C), - B.push({ type: _.REPETITION, min: 0, max: 1 / 0, value: B.pop() }); + (0 === B.length && repeatErr(C), + B.push({ type: _.REPETITION, min: 0, max: 1 / 0, value: B.pop() })); break; default: B.push({ type: _.CHAR, value: i.charCodeAt(0) }); } - return 0 !== $.length && u.error(s, 'Unterminated group'), j; + return (0 !== $.length && u.error(s, 'Unterminated group'), j); }), - (s.exports.types = _); + (s.exports.types = _)); }, 8048: (s, o, i) => { const u = i(6205); - (o.wordBoundary = () => ({ type: u.POSITION, value: 'b' })), + ((o.wordBoundary = () => ({ type: u.POSITION, value: 'b' })), (o.nonWordBoundary = () => ({ type: u.POSITION, value: 'B' })), (o.begin = () => ({ type: u.POSITION, value: '^' })), - (o.end = () => ({ type: u.POSITION, value: '$' })); + (o.end = () => ({ type: u.POSITION, value: '$' }))); }, 10023: (s, o, i) => { const u = i(6205), @@ -26096,7 +26129,7 @@ { type: u.CHAR, value: 12288 }, { type: u.CHAR, value: 65279 } ]; - (o.words = () => ({ type: u.SET, set: WORDS(), not: !1 })), + ((o.words = () => ({ type: u.SET, set: WORDS(), not: !1 })), (o.notWords = () => ({ type: u.SET, set: WORDS(), not: !0 })), (o.ints = () => ({ type: u.SET, set: INTS(), not: !1 })), (o.notInts = () => ({ type: u.SET, set: INTS(), not: !0 })), @@ -26111,7 +26144,7 @@ { type: u.CHAR, value: 8233 } ], not: !0 - })); + }))); }, 6205: (s) => { s.exports = { @@ -26129,7 +26162,7 @@ const u = i(6205), _ = i(10023), w = { 0: 0, t: 9, n: 10, v: 11, f: 12, r: 13 }; - (o.strToChars = function (s) { + ((o.strToChars = function (s) { return (s = s.replace( /(\[\\b\])|(\\)?\\(?:u([A-F0-9]{4})|x([A-F0-9]{2})|(0?[0-7]{2})|c([@A-Z[\\\]^?])|([0tnvfr]))/g, function (s, o, i, u, _, x, C, j) { @@ -26146,7 +26179,7 @@ ? '@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^ ?'.indexOf(C) : w[j], B = String.fromCharCode(L); - return /[[\]{}^$.|?*+()]/.test(B) && (B = '\\' + B), B; + return (/[[\]{}^$.|?*+()]/.test(B) && (B = '\\' + B), B); } )); }), @@ -26158,7 +26191,6 @@ j = /\\(?:(w)|(d)|(s)|(W)|(D)|(S))|((?:(?:\\)(.)|([^\]\\]))-(?:\\)?([^\]]))|(\])|(?:\\)?([^])/g; null != (w = j.exec(s)); - ) if (w[1]) C.push(_.words()); else if (w[2]) C.push(_.ints()); @@ -26180,7 +26212,7 @@ }), (o.error = (s, o) => { throw new SyntaxError('Invalid regular expression: /' + s + '/: ' + o); - }); + })); }, 92861: (s, o, i) => { var u = i(48287), @@ -26191,7 +26223,7 @@ function SafeBuffer(s, o, i) { return _(s, o, i); } - _.from && _.alloc && _.allocUnsafe && _.allocUnsafeSlow + (_.from && _.alloc && _.allocUnsafe && _.allocUnsafeSlow ? (s.exports = u) : (copyProps(u, o), (o.Buffer = SafeBuffer)), (SafeBuffer.prototype = Object.create(_.prototype)), @@ -26204,7 +26236,8 @@ if ('number' != typeof s) throw new TypeError('Argument must be a number'); var u = _(s); return ( - void 0 !== o ? ('string' == typeof i ? u.fill(o, i) : u.fill(o)) : u.fill(0), u + void 0 !== o ? ('string' == typeof i ? u.fill(o, i) : u.fill(o)) : u.fill(0), + u ); }), (SafeBuffer.allocUnsafe = function (s) { @@ -26214,7 +26247,7 @@ (SafeBuffer.allocUnsafeSlow = function (s) { if ('number' != typeof s) throw new TypeError('Argument must be a number'); return u.SlowBuffer(s); - }); + })); }, 29844: (s, o) => { 'use strict'; @@ -26225,7 +26258,7 @@ var u = (i - 1) >>> 1, _ = s[u]; if (!(0 < g(_, o))) break e; - (s[u] = o), (s[i] = _), (i = u); + ((s[u] = o), (s[i] = _), (i = u)); } } function h(s) { @@ -26248,7 +26281,7 @@ : ((s[u] = C), (s[x] = i), (u = x)); else { if (!(j < _ && 0 > g(L, i))) break e; - (s[u] = L), (s[j] = i), (u = j); + ((s[u] = L), (s[j] = i), (u = j)); } } } @@ -26286,42 +26319,42 @@ if (null === o.callback) k(x); else { if (!(o.startTime <= s)) break; - k(x), (o.sortIndex = o.expirationTime), f(w, o); + (k(x), (o.sortIndex = o.expirationTime), f(w, o)); } o = h(x); } } function H(s) { if (((V = !1), G(s), !$)) - if (null !== h(w)) ($ = !0), I(J); + if (null !== h(w)) (($ = !0), I(J)); else { var o = h(x); null !== o && K(H, o.startTime - s); } } function J(s, i) { - ($ = !1), V && ((V = !1), z(ae), (ae = -1)), (B = !0); + (($ = !1), V && ((V = !1), z(ae), (ae = -1)), (B = !0)); var u = L; try { for (G(i), j = h(w); null !== j && (!(j.expirationTime > i) || (s && !M())); ) { var _ = j.callback; if ('function' == typeof _) { - (j.callback = null), (L = j.priorityLevel); + ((j.callback = null), (L = j.priorityLevel)); var C = _(j.expirationTime <= i); - (i = o.unstable_now()), + ((i = o.unstable_now()), 'function' == typeof C ? (j.callback = C) : j === h(w) && k(w), - G(i); + G(i)); } else k(w); j = h(w); } if (null !== j) var U = !0; else { var Y = h(x); - null !== Y && K(H, Y.startTime - i), (U = !1); + (null !== Y && K(H, Y.startTime - i), (U = !1)); } return U; } finally { - (j = null), (L = u), (B = !1); + ((j = null), (L = u), (B = !1)); } } 'undefined' != typeof navigator && @@ -26356,23 +26389,23 @@ else if ('undefined' != typeof MessageChannel) { var pe = new MessageChannel(), de = pe.port2; - (pe.port1.onmessage = R), + ((pe.port1.onmessage = R), (Z = function () { de.postMessage(null); - }); + })); } else Z = function () { U(R, 0); }; function I(s) { - (ie = s), ee || ((ee = !0), Z()); + ((ie = s), ee || ((ee = !0), Z())); } function K(s, i) { ae = U(function () { s(o.unstable_now()); }, i); } - (o.unstable_IdlePriority = 5), + ((o.unstable_IdlePriority = 5), (o.unstable_ImmediatePriority = 1), (o.unstable_LowPriority = 4), (o.unstable_NormalPriority = 3), @@ -26488,7 +26521,7 @@ L = i; } }; - }); + })); }, 69982: (s, o, i) => { 'use strict'; @@ -26499,13 +26532,13 @@ var u = i(48287).Buffer; class NonError extends Error { constructor(s) { - super(NonError._prepareSuperMessage(s)), + (super(NonError._prepareSuperMessage(s)), Object.defineProperty(this, 'name', { value: 'NonError', configurable: !0, writable: !0 }), - Error.captureStackTrace && Error.captureStackTrace(this, NonError); + Error.captureStackTrace && Error.captureStackTrace(this, NonError)); } static _prepareSuperMessage(s) { try { @@ -26536,7 +26569,7 @@ return ((s) => { s[w] = !0; const o = s.toJSON(); - return delete s[w], o; + return (delete s[w], o); })(s); for (const [i, _] of Object.entries(s)) 'function' == typeof u && u.isBuffer(_) @@ -26578,7 +26611,7 @@ if (s instanceof Error) return s; if ('object' == typeof s && null !== s && !Array.isArray(s)) { const o = new Error(); - return destroyCircular({ from: s, seen: [], to_: o, maxDepth: i, depth: 0 }), o; + return (destroyCircular({ from: s, seen: [], to_: o, maxDepth: i, depth: 0 }), o); } return new NonError(s); } @@ -26587,36 +26620,35 @@ 90392: (s, o, i) => { var u = i(92861).Buffer; function Hash(s, o) { - (this._block = u.alloc(s)), + ((this._block = u.alloc(s)), (this._finalSize = o), (this._blockSize = s), - (this._len = 0); + (this._len = 0)); } - (Hash.prototype.update = function (s, o) { + ((Hash.prototype.update = function (s, o) { 'string' == typeof s && ((o = o || 'utf8'), (s = u.from(s, o))); for ( var i = this._block, _ = this._blockSize, w = s.length, x = this._len, C = 0; C < w; - ) { for (var j = x % _, L = Math.min(w - C, _ - j), B = 0; B < L; B++) i[j + B] = s[C + B]; - (C += L), (x += L) % _ == 0 && this._update(i); + ((C += L), (x += L) % _ == 0 && this._update(i)); } - return (this._len += w), this; + return ((this._len += w), this); }), (Hash.prototype.digest = function (s) { var o = this._len % this._blockSize; - (this._block[o] = 128), + ((this._block[o] = 128), this._block.fill(0, o + 1), - o >= this._finalSize && (this._update(this._block), this._block.fill(0)); + o >= this._finalSize && (this._update(this._block), this._block.fill(0))); var i = 8 * this._len; if (i <= 4294967295) this._block.writeUInt32BE(i, this._blockSize - 4); else { var u = (4294967295 & i) >>> 0, _ = (i - u) / 4294967296; - this._block.writeUInt32BE(_, this._blockSize - 8), - this._block.writeUInt32BE(u, this._blockSize - 4); + (this._block.writeUInt32BE(_, this._blockSize - 8), + this._block.writeUInt32BE(u, this._blockSize - 4)); } this._update(this._block); var w = this._hash(); @@ -26625,7 +26657,7 @@ (Hash.prototype._update = function () { throw new Error('_update must be implemented by subclass'); }), - (s.exports = Hash); + (s.exports = Hash)); }, 62802: (s, o, i) => { var u = (s.exports = function SHA(s) { @@ -26634,12 +26666,12 @@ if (!o) throw new Error(s + ' is not supported (we accept pull requests)'); return new o(); }); - (u.sha = i(27816)), + ((u.sha = i(27816)), (u.sha1 = i(63737)), (u.sha224 = i(26710)), (u.sha256 = i(24107)), (u.sha384 = i(32827)), - (u.sha512 = i(82890)); + (u.sha512 = i(82890))); }, 27816: (s, o, i) => { var u = i(56698), @@ -26648,7 +26680,7 @@ x = [1518500249, 1859775393, -1894007588, -899497514], C = new Array(80); function Sha() { - this.init(), (this._w = C), _.call(this, 64, 56); + (this.init(), (this._w = C), _.call(this, 64, 56)); } function rotl30(s) { return (s << 30) | (s >>> 2); @@ -26656,7 +26688,7 @@ function ft(s, o, i, u) { return 0 === s ? (o & i) | (~o & u) : 2 === s ? (o & i) | (o & u) | (i & u) : o ^ i ^ u; } - u(Sha, _), + (u(Sha, _), (Sha.prototype.init = function () { return ( (this._a = 1732584193), @@ -26685,13 +26717,13 @@ for (var B = 0; B < 80; ++B) { var $ = ~~(B / 20), V = 0 | ((((o = u) << 5) | (o >>> 27)) + ft($, _, w, C) + j + i[B] + x[$]); - (j = C), (C = w), (w = rotl30(_)), (_ = u), (u = V); + ((j = C), (C = w), (w = rotl30(_)), (_ = u), (u = V)); } - (this._a = (u + this._a) | 0), + ((this._a = (u + this._a) | 0), (this._b = (_ + this._b) | 0), (this._c = (w + this._c) | 0), (this._d = (C + this._d) | 0), - (this._e = (j + this._e) | 0); + (this._e = (j + this._e) | 0)); }), (Sha.prototype._hash = function () { var s = w.allocUnsafe(20); @@ -26704,7 +26736,7 @@ s ); }), - (s.exports = Sha); + (s.exports = Sha)); }, 63737: (s, o, i) => { var u = i(56698), @@ -26713,7 +26745,7 @@ x = [1518500249, 1859775393, -1894007588, -899497514], C = new Array(80); function Sha1() { - this.init(), (this._w = C), _.call(this, 64, 56); + (this.init(), (this._w = C), _.call(this, 64, 56)); } function rotl5(s) { return (s << 5) | (s >>> 27); @@ -26724,7 +26756,7 @@ function ft(s, o, i, u) { return 0 === s ? (o & i) | (~o & u) : 2 === s ? (o & i) | (o & u) | (i & u) : o ^ i ^ u; } - u(Sha1, _), + (u(Sha1, _), (Sha1.prototype.init = function () { return ( (this._a = 1732584193), @@ -26754,13 +26786,13 @@ for (var B = 0; B < 80; ++B) { var $ = ~~(B / 20), V = (rotl5(u) + ft($, _, w, C) + j + i[B] + x[$]) | 0; - (j = C), (C = w), (w = rotl30(_)), (_ = u), (u = V); + ((j = C), (C = w), (w = rotl30(_)), (_ = u), (u = V)); } - (this._a = (u + this._a) | 0), + ((this._a = (u + this._a) | 0), (this._b = (_ + this._b) | 0), (this._c = (w + this._c) | 0), (this._d = (C + this._d) | 0), - (this._e = (j + this._e) | 0); + (this._e = (j + this._e) | 0)); }), (Sha1.prototype._hash = function () { var s = w.allocUnsafe(20); @@ -26773,7 +26805,7 @@ s ); }), - (s.exports = Sha1); + (s.exports = Sha1)); }, 26710: (s, o, i) => { var u = i(56698), @@ -26782,9 +26814,9 @@ x = i(92861).Buffer, C = new Array(64); function Sha224() { - this.init(), (this._w = C), w.call(this, 64, 56); + (this.init(), (this._w = C), w.call(this, 64, 56)); } - u(Sha224, _), + (u(Sha224, _), (Sha224.prototype.init = function () { return ( (this._a = 3238371032), @@ -26811,7 +26843,7 @@ s ); }), - (s.exports = Sha224); + (s.exports = Sha224)); }, 24107: (s, o, i) => { var u = i(56698), @@ -26831,7 +26863,7 @@ ], C = new Array(64); function Sha256() { - this.init(), (this._w = C), _.call(this, 64, 56); + (this.init(), (this._w = C), _.call(this, 64, 56)); } function ch(s, o, i) { return i ^ (s & (o ^ i)); @@ -26848,7 +26880,7 @@ function gamma0(s) { return ((s >>> 7) | (s << 25)) ^ ((s >>> 18) | (s << 14)) ^ (s >>> 3); } - u(Sha256, _), + (u(Sha256, _), (Sha256.prototype.init = function () { return ( (this._a = 1779033703), @@ -26889,23 +26921,23 @@ for (var U = 0; U < 64; ++U) { var z = ($ + sigma1(j) + ch(j, L, B) + x[U] + i[U]) | 0, Y = (sigma0(u) + maj(u, _, w)) | 0; - ($ = B), + (($ = B), (B = L), (L = j), (j = (C + z) | 0), (C = w), (w = _), (_ = u), - (u = (z + Y) | 0); + (u = (z + Y) | 0)); } - (this._a = (u + this._a) | 0), + ((this._a = (u + this._a) | 0), (this._b = (_ + this._b) | 0), (this._c = (w + this._c) | 0), (this._d = (C + this._d) | 0), (this._e = (j + this._e) | 0), (this._f = (L + this._f) | 0), (this._g = (B + this._g) | 0), - (this._h = ($ + this._h) | 0); + (this._h = ($ + this._h) | 0)); }), (Sha256.prototype._hash = function () { var s = w.allocUnsafe(32); @@ -26921,7 +26953,7 @@ s ); }), - (s.exports = Sha256); + (s.exports = Sha256)); }, 32827: (s, o, i) => { var u = i(56698), @@ -26930,9 +26962,9 @@ x = i(92861).Buffer, C = new Array(160); function Sha384() { - this.init(), (this._w = C), w.call(this, 128, 112); + (this.init(), (this._w = C), w.call(this, 128, 112)); } - u(Sha384, _), + (u(Sha384, _), (Sha384.prototype.init = function () { return ( (this._ah = 3418070365), @@ -26957,7 +26989,7 @@ (Sha384.prototype._hash = function () { var s = x.allocUnsafe(48); function writeInt64BE(o, i, u) { - s.writeInt32BE(o, u), s.writeInt32BE(i, u + 4); + (s.writeInt32BE(o, u), s.writeInt32BE(i, u + 4)); } return ( writeInt64BE(this._ah, this._al, 0), @@ -26969,7 +27001,7 @@ s ); }), - (s.exports = Sha384); + (s.exports = Sha384)); }, 82890: (s, o, i) => { var u = i(56698), @@ -27002,7 +27034,7 @@ ], C = new Array(160); function Sha512() { - this.init(), (this._w = C), _.call(this, 128, 112); + (this.init(), (this._w = C), _.call(this, 128, 112)); } function Ch(s, o, i) { return i ^ (s & (o ^ i)); @@ -27031,7 +27063,7 @@ function getCarry(s, o) { return s >>> 0 < o >>> 0 ? 1 : 0; } - u(Sha512, _), + (u(Sha512, _), (Sha512.prototype.init = function () { return ( (this._ah = 1779033703), @@ -27076,7 +27108,7 @@ ae < 32; ae += 2 ) - (o[ae] = s.readInt32BE(4 * ae)), (o[ae + 1] = s.readInt32BE(4 * ae + 4)); + ((o[ae] = s.readInt32BE(4 * ae)), (o[ae + 1] = s.readInt32BE(4 * ae + 4))); for (; ae < 160; ae += 2) { var le = o[ae - 30], ce = o[ae - 30 + 1], @@ -27090,16 +27122,16 @@ Se = o[ae - 32 + 1], xe = (de + _e) | 0, Pe = (pe + be + getCarry(xe, de)) | 0; - (Pe = + ((Pe = ((Pe = (Pe + fe + getCarry((xe = (xe + ye) | 0), ye)) | 0) + we + getCarry((xe = (xe + Se) | 0), Se)) | 0), (o[ae] = Pe), - (o[ae + 1] = xe); + (o[ae + 1] = xe)); } for (var Te = 0; Te < 160; Te += 2) { - (Pe = o[Te]), (xe = o[Te + 1]); + ((Pe = o[Te]), (xe = o[Te + 1])); var Re = maj(i, u, _), qe = maj($, V, U), $e = sigma0(i, $), @@ -27123,7 +27155,7 @@ 0; var nt = (ze + qe) | 0, st = ($e + Re + getCarry(nt, ze)) | 0; - (B = L), + ((B = L), (ie = ee), (L = j), (ee = Z), @@ -27136,9 +27168,9 @@ (U = V), (u = i), (V = $), - (i = (rt + st + getCarry(($ = (tt + nt) | 0), tt)) | 0); + (i = (rt + st + getCarry(($ = (tt + nt) | 0), tt)) | 0)); } - (this._al = (this._al + $) | 0), + ((this._al = (this._al + $) | 0), (this._bl = (this._bl + V) | 0), (this._cl = (this._cl + U) | 0), (this._dl = (this._dl + z) | 0), @@ -27153,12 +27185,12 @@ (this._eh = (this._eh + C + getCarry(this._el, Y)) | 0), (this._fh = (this._fh + j + getCarry(this._fl, Z)) | 0), (this._gh = (this._gh + L + getCarry(this._gl, ee)) | 0), - (this._hh = (this._hh + B + getCarry(this._hl, ie)) | 0); + (this._hh = (this._hh + B + getCarry(this._hl, ie)) | 0)); }), (Sha512.prototype._hash = function () { var s = w.allocUnsafe(64); function writeInt64BE(o, i, u) { - s.writeInt32BE(o, u), s.writeInt32BE(i, u + 4); + (s.writeInt32BE(o, u), s.writeInt32BE(i, u + 4)); } return ( writeInt64BE(this._ah, this._al, 0), @@ -27172,7 +27204,7 @@ s ); }), - (s.exports = Sha512); + (s.exports = Sha512)); }, 8068: (s) => { 'use strict'; @@ -27193,7 +27225,8 @@ return s; }, __publicField = (s, o, i) => ( - __defNormalProp(s, 'symbol' != typeof o ? o + '' : o, i), i + __defNormalProp(s, 'symbol' != typeof o ? o + '' : o, i), + i ), x = {}; ((o, i) => { @@ -27203,7 +27236,7 @@ j = { dictionary: 'alphanum', shuffle: !0, debug: !1, length: C, counter: 0 }, L = class _ShortUniqueId { constructor(s = {}) { - __publicField(this, 'counter'), + (__publicField(this, 'counter'), __publicField(this, 'debug'), __publicField(this, 'dict'), __publicField(this, 'version'), @@ -27273,7 +27306,7 @@ if (s && Array.isArray(s) && s.length > 1) i = s; else { let o; - (i = []), (this.dictIndex = o = 0); + ((i = []), (this.dictIndex = o = 0)); const u = `_${s}_dict_ranges`, _ = this._dict_ranges[u]; Object.keys(_).forEach((s) => { @@ -27299,9 +27332,9 @@ return i; }), __publicField(this, 'setDictionary', (s, o) => { - (this.dict = this._normalizeDictionary(s, o)), + ((this.dict = this._normalizeDictionary(s, o)), (this.dictLength = this.dict.length), - this.setCounter(0); + this.setCounter(0)); }), __publicField(this, 'seq', () => this.sequentialUUID()), __publicField(this, 'sequentialUUID', () => { @@ -27310,21 +27343,21 @@ i = ''; s = this.counter; do { - (o = s % this.dictLength), + ((o = s % this.dictLength), (s = Math.trunc(s / this.dictLength)), - (i += this.dict[o]); + (i += this.dict[o])); } while (0 !== s); - return (this.counter += 1), i; + return ((this.counter += 1), i); }), __publicField(this, 'rnd', (s = this.uuidLength || C) => this.randomUUID(s)), __publicField(this, 'randomUUID', (s = this.uuidLength || C) => { let o, i, u; if (null == s || s < 1) throw new Error('Invalid UUID Length Provided'); for (o = '', u = 0; u < s; u += 1) - (i = + ((i = parseInt((Math.random() * this.dictLength).toFixed(0), 10) % this.dictLength), - (o += this.dict[i]); + (o += this.dict[i])); return o; }), __publicField(this, 'fmt', (s, o) => this.formattedUUID(s, o)), @@ -27415,9 +27448,12 @@ __publicField(this, 'validate', (s, o) => { const i = o ? this._normalizeDictionary(o) : this.dict; return s.split('').every((s) => i.includes(s)); - }); + })); const o = __spreadValues(__spreadValues({}, j), s); - (this.counter = 0), (this.debug = !1), (this.dict = []), (this.version = '5.2.0'); + ((this.counter = 0), + (this.debug = !1), + (this.dict = []), + (this.version = '5.2.0')); const { dictionary: i, shuffle: u, length: _, counter: w } = o; return ( (this.uuidLength = _), @@ -27463,7 +27499,7 @@ })(s({}, '__esModule', { value: !0 }), B) ); })(); - (s.exports = o.default), 'undefined' != typeof window && (o = o.default); + ((s.exports = o.default), 'undefined' != typeof window && (o = o.default)); }, 88310: (s, o, i) => { s.exports = Stream; @@ -27471,7 +27507,7 @@ function Stream() { u.call(this); } - i(56698)(Stream, u), + (i(56698)(Stream, u), (Stream.Readable = i(45412)), (Stream.Writable = i(16708)), (Stream.Duplex = i(25382)), @@ -27488,9 +27524,9 @@ function ondrain() { i.readable && i.resume && i.resume(); } - i.on('data', ondata), + (i.on('data', ondata), s.on('drain', ondrain), - s._isStdio || (o && !1 === o.end) || (i.on('end', onend), i.on('close', onclose)); + s._isStdio || (o && !1 === o.end) || (i.on('end', onend), i.on('close', onclose))); var _ = !1; function onend() { _ || ((_ = !0), s.end()); @@ -27502,7 +27538,7 @@ if ((cleanup(), 0 === u.listenerCount(this, 'error'))) throw s; } function cleanup() { - i.removeListener('data', ondata), + (i.removeListener('data', ondata), s.removeListener('drain', ondrain), i.removeListener('end', onend), i.removeListener('close', onclose), @@ -27510,7 +27546,7 @@ s.removeListener('error', onerror), i.removeListener('end', cleanup), i.removeListener('close', cleanup), - s.removeListener('close', cleanup); + s.removeListener('close', cleanup)); } return ( i.on('error', onerror), @@ -27521,7 +27557,7 @@ s.emit('pipe', i), s ); - }); + })); }, 83141: (s, o, i) => { 'use strict'; @@ -27571,7 +27607,7 @@ return s; default: if (o) return; - (s = ('' + s).toLowerCase()), (o = !0); + ((s = ('' + s).toLowerCase()), (o = !0)); } })(s); if ('string' != typeof o && (u.isEncoding === _ || !_(s))) @@ -27581,18 +27617,18 @@ this.encoding) ) { case 'utf16le': - (this.text = utf16Text), (this.end = utf16End), (o = 4); + ((this.text = utf16Text), (this.end = utf16End), (o = 4)); break; case 'utf8': - (this.fillLast = utf8FillLast), (o = 4); + ((this.fillLast = utf8FillLast), (o = 4)); break; case 'base64': - (this.text = base64Text), (this.end = base64End), (o = 3); + ((this.text = base64Text), (this.end = base64End), (o = 3)); break; default: - return (this.write = simpleWrite), void (this.end = simpleEnd); + return ((this.write = simpleWrite), void (this.end = simpleEnd)); } - (this.lastNeed = 0), (this.lastTotal = 0), (this.lastChar = u.allocUnsafe(o)); + ((this.lastNeed = 0), (this.lastTotal = 0), (this.lastChar = u.allocUnsafe(o))); } function utf8CheckByte(s) { return s <= 127 @@ -27610,11 +27646,11 @@ function utf8FillLast(s) { var o = this.lastTotal - this.lastNeed, i = (function utf8CheckExtraBytes(s, o, i) { - if (128 != (192 & o[0])) return (s.lastNeed = 0), '๏ฟฝ'; + if (128 != (192 & o[0])) return ((s.lastNeed = 0), '๏ฟฝ'); if (s.lastNeed > 1 && o.length > 1) { - if (128 != (192 & o[1])) return (s.lastNeed = 1), '๏ฟฝ'; + if (128 != (192 & o[1])) return ((s.lastNeed = 1), '๏ฟฝ'); if (s.lastNeed > 2 && o.length > 2 && 128 != (192 & o[2])) - return (s.lastNeed = 2), '๏ฟฝ'; + return ((s.lastNeed = 2), '๏ฟฝ'); } })(this, s); return void 0 !== i @@ -27676,13 +27712,13 @@ function simpleEnd(s) { return s && s.length ? this.write(s) : ''; } - (o.I = StringDecoder), + ((o.I = StringDecoder), (StringDecoder.prototype.write = function (s) { if (0 === s.length) return ''; var o, i; if (this.lastNeed) { if (void 0 === (o = this.fillLast(s))) return ''; - (i = this.lastNeed), (this.lastNeed = 0); + ((i = this.lastNeed), (this.lastNeed = 0)); } else i = 0; return i < s.length ? (o ? o + this.text(s, i) : this.text(s, i)) : o || ''; }), @@ -27695,18 +27731,18 @@ var u = o.length - 1; if (u < i) return 0; var _ = utf8CheckByte(o[u]); - if (_ >= 0) return _ > 0 && (s.lastNeed = _ - 1), _; + if (_ >= 0) return (_ > 0 && (s.lastNeed = _ - 1), _); if (--u < i || -2 === _) return 0; - if (((_ = utf8CheckByte(o[u])), _ >= 0)) return _ > 0 && (s.lastNeed = _ - 2), _; + if (((_ = utf8CheckByte(o[u])), _ >= 0)) return (_ > 0 && (s.lastNeed = _ - 2), _); if (--u < i || -2 === _) return 0; if (((_ = utf8CheckByte(o[u])), _ >= 0)) - return _ > 0 && (2 === _ ? (_ = 0) : (s.lastNeed = _ - 3)), _; + return (_ > 0 && (2 === _ ? (_ = 0) : (s.lastNeed = _ - 3)), _); return 0; })(this, s, o); if (!this.lastNeed) return s.toString('utf8', o); this.lastTotal = i; var u = s.length - (i - this.lastNeed); - return s.copy(this.lastChar, 0, u), s.toString('utf8', o, u); + return (s.copy(this.lastChar, 0, u), s.toString('utf8', o, u)); }), (StringDecoder.prototype.fillLast = function (s) { if (this.lastNeed <= s.length) @@ -27714,13 +27750,13 @@ s.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, this.lastNeed), this.lastChar.toString(this.encoding, 0, this.lastTotal) ); - s.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, s.length), - (this.lastNeed -= s.length); - }); + (s.copy(this.lastChar, this.lastTotal - this.lastNeed, 0, s.length), + (this.lastNeed -= s.length)); + })); }, 69883: (s, o) => { 'use strict'; - (o.parse = function parse(s, o) { + ((o.parse = function parse(s, o) { if ('string' != typeof s) throw new TypeError('argument str must be a string'); var i = {}, _ = s.length; @@ -27819,7 +27855,7 @@ } } return B; - }); + })); var i = Object.prototype.toString, u = Object.prototype.hasOwnProperty, _ = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/, @@ -27869,12 +27905,12 @@ return ( s.removeAllRanges(), function () { - 'Caret' === s.type && s.removeAllRanges(), + ('Caret' === s.type && s.removeAllRanges(), s.rangeCount || i.forEach(function (o) { s.addRange(o); }), - o && o.focus(); + o && o.focus()); } ); }; @@ -27937,7 +27973,7 @@ ); } function extractProtocol(s, o) { - (s = (s = trimLeft(s)).replace(x, '')), (o = o || {}); + ((s = (s = trimLeft(s)).replace(x, '')), (o = o || {})); var i, u = L.exec(s), _ = u[1] ? u[1].toLowerCase() : '', @@ -28002,7 +28038,7 @@ (Z[U] = Z[U] || (w && L[3] && o[U]) || ''), L[4] && (Z[U] = Z[U].toLowerCase())) : (s = L(s, Z)); - i && (Z.query = i(Z.query)), + (i && (Z.query = i(Z.query)), w && o.slashes && '/' !== Z.pathname.charAt(0) && @@ -28016,14 +28052,13 @@ w = !1, x = 0; u--; - ) '.' === i[u] ? i.splice(u, 1) : '..' === i[u] ? (i.splice(u, 1), x++) : x && (0 === u && (w = !0), i.splice(u, 1), x--); - return w && i.unshift(''), ('.' !== _ && '..' !== _) || i.push(''), i.join('/'); + return (w && i.unshift(''), ('.' !== _ && '..' !== _) || i.push(''), i.join('/')); })(Z.pathname, o.pathname)), '/' !== Z.pathname.charAt(0) && isSpecial(Z.protocol) && @@ -28042,32 +28077,32 @@ 'file:' !== Z.protocol && isSpecial(Z.protocol) && Z.host ? Z.protocol + '//' + Z.host : 'null'), - (Z.href = Z.toString()); + (Z.href = Z.toString())); } - (Url.prototype = { + ((Url.prototype = { set: function set(s, o, i) { var w = this; switch (s) { case 'query': - 'string' == typeof o && o.length && (o = (i || _.parse)(o)), (w[s] = o); + ('string' == typeof o && o.length && (o = (i || _.parse)(o)), (w[s] = o)); break; case 'port': - (w[s] = o), + ((w[s] = o), u(o, w.protocol) ? o && (w.host = w.hostname + ':' + o) - : ((w.host = w.hostname), (w[s] = '')); + : ((w.host = w.hostname), (w[s] = ''))); break; case 'hostname': - (w[s] = o), w.port && (o += ':' + w.port), (w.host = o); + ((w[s] = o), w.port && (o += ':' + w.port), (w.host = o)); break; case 'host': - (w[s] = o), + ((w[s] = o), j.test(o) ? ((o = o.split(':')), (w.port = o.pop()), (w.hostname = o.join(':'))) - : ((w.hostname = o), (w.port = '')); + : ((w.hostname = o), (w.port = ''))); break; case 'protocol': - (w.protocol = o.toLowerCase()), (w.slashes = !i); + ((w.protocol = o.toLowerCase()), (w.slashes = !i)); break; case 'pathname': case 'hash': @@ -28134,7 +28169,7 @@ (Url.location = lolcation), (Url.trimLeft = trimLeft), (Url.qs = _), - (s.exports = Url); + (s.exports = Url)); }, 77154: (s, o, i) => { 'use strict'; @@ -28191,7 +28226,7 @@ return ( C( function () { - (V.hasValue = !0), (V.value = U); + ((V.hasValue = !0), (V.value = U)); }, [U] ), @@ -28220,7 +28255,7 @@ return function deprecated() { if (!i) { if (config('throwDeprecation')) throw new Error(o); - config('traceDeprecation') ? console.trace(o) : console.warn(o), (i = !0); + (config('traceDeprecation') ? console.trace(o) : console.warn(o), (i = !0)); } return s.apply(this, arguments); }; @@ -28337,7 +28372,7 @@ switch (typeof x) { case 'object': if (null === x) break; - x._attr && get_attributes(x._attr), + (x._attr && get_attributes(x._attr), x._cdata && L.push(('/g, ']]]]>') + ']]>'), x.forEach && @@ -28350,7 +28385,7 @@ : L.push(resolve(s, o, i + 1)) : (L.pop(), (C = !0), L.push(_(s))); }), - C || L.push('')); + C || L.push(''))); break; default: L.push(_(x)); @@ -28376,13 +28411,13 @@ format(s, _); } } - s( + (s( !1, (u > 1 ? o.indents : '') + (o.name ? '' : '') + (o.indent && !i ? '\n' : '') ), - i && i(); + i && i()); } function interrupt(o) { return ( @@ -28408,7 +28443,7 @@ return s(!1, o.indent ? '\n' : ''); interrupt(o) || proceed(); } - (s.exports = function xml(s, o) { + ((s.exports = function xml(s, o) { 'object' != typeof o && (o = { indent: o }); var i = o.stream ? new w() : null, _ = '', @@ -28421,10 +28456,10 @@ function append(s, o) { if ((void 0 !== o && (_ += o), s && !x && ((i = i || new w()), (x = !0)), s && x)) { var u = _; - delay(function () { + (delay(function () { i.emit('data', u); }), - (_ = ''); + (_ = '')); } } function add(s, o) { @@ -28434,7 +28469,7 @@ if (i) { var s = _; delay(function () { - i.emit('data', s), i.emit('end'), (i.readable = !1), i.emit('close'); + (i.emit('data', s), i.emit('end'), (i.readable = !1), i.emit('close')); }); } } @@ -28445,14 +28480,14 @@ o.declaration && (function addXmlDeclaration(s) { var o = { version: '1.0', encoding: s.encoding || 'UTF-8' }; - s.standalone && (o.standalone = s.standalone), + (s.standalone && (o.standalone = s.standalone), add({ '?xml': { _attr: o } }), - (_ = _.replace('/>', '?>')); + (_ = _.replace('/>', '?>'))); })(o.declaration), s && s.forEach ? s.forEach(function (o, i) { var u; - i + 1 === s.length && (u = end), add(o, u); + (i + 1 === s.length && (u = end), add(o, u)); }) : add(s, end), i ? ((i.readable = !0), i) : _ @@ -28475,15 +28510,15 @@ ); }, close: function (s) { - void 0 !== s && this.push(s), this.end && this.end(); + (void 0 !== s && this.push(s), this.end && this.end()); } }; return s; - }); + })); }, 86215: function (s, o) { var i, u, _; - (u = []), + ((u = []), (i = (function () { 'use strict'; var isNativeSmoothScrollEnabledOn = function (s) { @@ -28496,12 +28531,12 @@ if ('undefined' == typeof window || !('document' in window)) return {}; var makeScroller = function (s, o, i) { var u; - (o = o || 999), i || 0 === i || (i = 9); + ((o = o || 999), i || 0 === i || (i = 9)); var setScrollTimeoutId = function (s) { u = s; }, stopScroll = function () { - clearTimeout(u), setScrollTimeoutId(0); + (clearTimeout(u), setScrollTimeoutId(0)); }, getTopWithEdgeOffset = function (o) { return Math.max(0, s.getTopOf(o) - i); @@ -28511,12 +28546,12 @@ (stopScroll(), 0 === u || (u && u < 0) || isNativeSmoothScrollEnabledOn(s.body)) ) - s.toY(i), _ && _(); + (s.toY(i), _ && _()); else { var w = s.getY(), x = Math.max(0, i) - w, C = new Date().getTime(); - (u = u || Math.min(Math.abs(x), o)), + ((u = u || Math.min(Math.abs(x), o)), (function loopScroll() { setScrollTimeoutId( setTimeout(function () { @@ -28525,13 +28560,13 @@ 0, Math.floor(w + x * (o < 0.5 ? 2 * o * o : o * (4 - 2 * o) - 1)) ); - s.toY(i), + (s.toY(i), o < 1 && s.getHeight() + i < s.body.scrollHeight ? loopScroll() - : (setTimeout(stopScroll, 99), _ && _()); + : (setTimeout(stopScroll, 99), _ && _())); }, 9) ); - })(); + })()); } }, scrollToElem = function (s, o, i) { @@ -28626,11 +28661,11 @@ ) { var i = 'history' in window && 'pushState' in history, u = i && 'scrollRestoration' in history; - u && (history.scrollRestoration = 'auto'), + (u && (history.scrollRestoration = 'auto'), window.addEventListener( 'load', function () { - u && + (u && (setTimeout(function () { history.scrollRestoration = 'manual'; }, 9), @@ -28652,10 +28687,10 @@ 0 <= _ && _ < 9 && window.scrollTo(0, u); } } - }, 9); + }, 9)); }, !1 - ); + )); var _ = new RegExp('(^|\\s)noZensmooth(\\s|$)'); window.addEventListener( 'click', @@ -28685,13 +28720,13 @@ window.location = C; }, B = o.setup().edgeOffset; - B && + (B && ((j = Math.max(0, j - B)), i && (onDone = function () { history.pushState({}, '', C); })), - o.toY(j, null, onDone); + o.toY(j, null, onDone)); } } }, @@ -28700,7 +28735,7 @@ } return o; })()), - void 0 === (_ = 'function' == typeof i ? i.apply(o, u) : i) || (s.exports = _); + void 0 === (_ = 'function' == typeof i ? i.apply(o, u) : i) || (s.exports = _)); }, 15340: () => {}, 79838: () => {}, @@ -28728,7 +28763,7 @@ _extends.apply(null, arguments) ); } - (s.exports = _extends), (s.exports.__esModule = !0), (s.exports.default = s.exports); + ((s.exports = _extends), (s.exports.__esModule = !0), (s.exports.default = s.exports)); }, 46942: (s, o) => { var i; @@ -28783,7 +28818,7 @@ }, 37257: (s, o, i) => { 'use strict'; - i(96605), i(64502), i(36371), i(99363), i(7057); + (i(96605), i(64502), i(36371), i(99363), i(7057)); var u = i(92046); s.exports = u.AggregateError; }, @@ -28959,7 +28994,10 @@ var u = i(98828); s.exports = !u(function () { function F() {} - return (F.prototype.constructor = null), Object.getPrototypeOf(new F()) !== F.prototype; + return ( + (F.prototype.constructor = null), + Object.getPrototypeOf(new F()) !== F.prototype + ); }); }, 59550: (s) => { @@ -28978,7 +29016,7 @@ return _.f(s, o, w(1, i)); } : function (s, o, i) { - return (s[o] = i), s; + return ((s[o] = i), s); }; }, 75817: (s) => { @@ -28991,7 +29029,7 @@ 'use strict'; var u = i(61626); s.exports = function (s, o, i, _) { - return _ && _.enumerable ? (s[o] = i) : u(s, o, i), s; + return (_ && _.enumerable ? (s[o] = i) : u(s, o, i), s); }; }, 2532: (s, o, i) => { @@ -29095,13 +29133,13 @@ j = w.Deno, L = (C && C.versions) || (j && j.version), B = L && L.v8; - B && (_ = (u = B.split('.'))[0] > 0 && u[0] < 4 ? 1 : +(u[0] + u[1])), + (B && (_ = (u = B.split('.'))[0] > 0 && u[0] < 4 ? 1 : +(u[0] + u[1])), !_ && x && (!(u = x.match(/Edge\/(\d+)/)) || u[1] >= 74) && (u = x.match(/Chrome\/(\d+)/)) && (_ = +u[1]), - (s.exports = _); + (s.exports = _)); }, 85762: (s, o, i) => { 'use strict'; @@ -29163,7 +29201,7 @@ } return _(s, this, arguments); }; - return (Wrapper.prototype = s.prototype), Wrapper; + return ((Wrapper.prototype = s.prototype), Wrapper); }; s.exports = function (s, o) { var i, @@ -29183,7 +29221,7 @@ ye = ce ? L : L[le] || $(L, le, {})[le], be = ye.prototype; for (z in o) - (_ = !(i = j(ce ? z : le + (pe ? '.' : '#') + z, s.forced)) && fe && V(fe, z)), + ((_ = !(i = j(ce ? z : le + (pe ? '.' : '#') + z, s.forced)) && fe && V(fe, z)), (Z = ye[z]), _ && (ee = s.dontCallGetSet ? (ae = C(fe, z)) && ae.value : fe[z]), (Y = _ && ee ? ee : o[z]), @@ -29201,7 +29239,7 @@ de && (V(L, (U = le + 'Prototype')) || $(L, U, {}), $(L[U], z, Y), - s.real && be && (i || !be[z]) && $(be, z, Y))); + s.real && be && (i || !be[z]) && $(be, z, Y)))); }; }, 98828: (s) => { @@ -29285,7 +29323,7 @@ })(o, i.length, i) : o.apply(s, i); }; - return w(i) && (j.prototype = i), j; + return (w(i) && (j.prototype = i), j); }; }, 13930: (s, o, i) => { @@ -29515,32 +29553,32 @@ Z = C.WeakMap; if (x || $.state) { var ee = $.state || ($.state = new Z()); - (ee.get = ee.get), + ((ee.get = ee.get), (ee.has = ee.has), (ee.set = ee.set), (u = function (s, o) { if (ee.has(s)) throw new Y(z); - return (o.facade = s), ee.set(s, o), o; + return ((o.facade = s), ee.set(s, o), o); }), (_ = function (s) { return ee.get(s) || {}; }), (w = function (s) { return ee.has(s); - }); + })); } else { var ie = V('state'); - (U[ie] = !0), + ((U[ie] = !0), (u = function (s, o) { if (B(s, ie)) throw new Y(z); - return (o.facade = s), L(s, ie, o), o; + return ((o.facade = s), L(s, ie, o), o); }), (_ = function (s) { return B(s, ie) ? s[ie] : {}; }), (w = function (s) { return B(s, ie); - }); + })); } s.exports = { set: u, @@ -29652,7 +29690,7 @@ V = i(40154), U = TypeError, Result = function (s, o) { - (this.stopped = s), (this.result = o); + ((this.stopped = s), (this.result = o)); }, z = Result.prototype; s.exports = function (s, o, i) { @@ -29670,7 +29708,7 @@ be = !(!i || !i.INTERRUPTED), _e = u(o, pe), stop = function (s) { - return Y && V(Y, 'normal', s), new Result(!0, s); + return (Y && V(Y, 'normal', s), new Result(!0, s)); }, callFn = function (s) { return de @@ -29716,11 +29754,11 @@ } x = u(x, s); } catch (s) { - (C = !0), (x = s); + ((C = !0), (x = s)); } if ('throw' === o) throw i; if (C) throw x; - return _(x), i; + return (_(x), i); }; }, 47181: (s, o, i) => { @@ -29736,7 +29774,10 @@ s.exports = function (s, o, i, j) { var L = o + ' Iterator'; return ( - (s.prototype = _(u, { next: w(+!j, i) })), x(s, L, !1, !0), (C[L] = returnThis), s + (s.prototype = _(u, { next: w(+!j, i) })), + x(s, L, !1, !0), + (C[L] = returnThis), + s ); }; }, @@ -29828,7 +29869,7 @@ ) for (we in _e) (le || xe || !(we in Pe)) && U(Pe, we, _e[we]); else u({ target: o, proto: !0, forced: le || xe }, _e); - return (w && !ye) || Pe[ce] === Re || U(Pe, ce, Re, { name: z }), (Y[o] = Re), _e; + return ((w && !ye) || Pe[ce] === Re || U(Pe, ce, Re, { name: z }), (Y[o] = Re), _e); }; }, 95116: (s, o, i) => { @@ -29846,7 +29887,7 @@ U = i(7376), z = V('iterator'), Y = !1; - [].keys && + ([].keys && ('next' in (w = [].keys()) ? (_ = B(B(w))) !== Object.prototype && (u = _) : (Y = !0)), !j(u) || x(function () { @@ -29859,7 +29900,7 @@ $(u, z, function () { return this; }), - (s.exports = { IteratorPrototype: u, BUGGY_SAFARI_ITERATORS: Y }); + (s.exports = { IteratorPrototype: u, BUGGY_SAFARI_ITERATORS: Y })); }, 93742: (s) => { 'use strict'; @@ -29945,9 +29986,8 @@ ie = ee.length, ae = 0; ie > ae; - ) - (Y = ee[ae++]), (u && !w(U, Z, Y)) || (i[Y] = Z[Y]); + ((Y = ee[ae++]), (u && !w(U, Z, Y)) || (i[Y] = Z[Y])); return i; } : V; @@ -29970,9 +30010,9 @@ return '<' + V + '>' + s + ''; }, NullProtoObjectViaActiveX = function (s) { - s.write(scriptTag('')), s.close(); + (s.write(scriptTag('')), s.close()); var o = s.parentWindow.Object; - return (s = null), o; + return ((s = null), o); }, NullProtoObject = function () { try { @@ -29996,7 +30036,7 @@ for (var _ = x.length; _--; ) delete NullProtoObject[$][x[_]]; return NullProtoObject(); }; - (C[U] = !0), + ((C[U] = !0), (s.exports = Object.create || function create(s, o) { @@ -30010,7 +30050,7 @@ : (i = NullProtoObject()), void 0 === o ? i : w.f(i, o) ); - }); + })); }, 42220: (s, o, i) => { 'use strict'; @@ -30071,7 +30111,7 @@ return L(s, o, i); } catch (s) {} if ('get' in i || 'set' in i) throw new j('Accessors not supported'); - return 'value' in i && (s[o] = i.value), s; + return ('value' in i && (s[o] = i.value), s); }; }, 13846: (s, o, i) => { @@ -30187,10 +30227,11 @@ o = !1, i = {}; try { - (s = u(Object.prototype, '__proto__', 'set'))(i, []), (o = i instanceof Array); + ((s = u(Object.prototype, '__proto__', 'set'))(i, []), + (o = i instanceof Array)); } catch (s) {} return function setPrototypeOf(i, u) { - return w(i), x(u), _(i) ? (o ? s(i, u) : (i.__proto__ = u), i) : i; + return (w(i), x(u), _(i) ? (o ? s(i, u) : (i.__proto__ = u), i) : i); }; })() : void 0); @@ -30417,7 +30458,7 @@ if ((void 0 === o && (o = 'default'), (i = u(j, s, o)), !_(i) || w(i))) return i; throw new L("Can't convert object to primitive value"); } - return void 0 === o && (o = 'number'), C(s, o); + return (void 0 === o && (o = 'number'), C(s, o)); }; }, 70470: (s, o, i) => { @@ -30432,7 +30473,7 @@ 52623: (s, o, i) => { 'use strict'; var u = {}; - (u[i(76264)('toStringTag')] = 'z'), (s.exports = '[object z]' === String(u)); + ((u[i(76264)('toStringTag')] = 'z'), (s.exports = '[object z]' === String(u))); }, 90160: (s, o, i) => { 'use strict'; @@ -30502,7 +30543,7 @@ B = _('wks'), $ = j ? L.for || L : (L && L.withoutSetter) || x; s.exports = function (s) { - return w(B, s) || (B[s] = C && w(L, s) ? L[s] : $('Symbol.' + s)), B[s]; + return (w(B, s) || (B[s] = C && w(L, s) ? L[s] : $('Symbol.' + s)), B[s]); }; }, 19358: (s, o, i) => { @@ -30552,7 +30593,7 @@ !Y) ) try { - pe.name !== le && w(pe, 'name', le), (pe.constructor = fe); + (pe.name !== le && w(pe, 'name', le), (pe.constructor = fe)); } catch (s) {} return fe; } @@ -30610,12 +30651,12 @@ ie = function AggregateError(s, o) { var i, u = _(ae, this); - x ? (i = x(new Z(), u ? w(this) : ae)) : ((i = u ? this : j(ae)), L(i, Y, 'Error')), + (x ? (i = x(new Z(), u ? w(this) : ae)) : ((i = u ? this : j(ae)), L(i, Y, 'Error')), void 0 !== o && L(i, 'message', z(o)), V(i, ie, i.stack, 1), - arguments.length > 2 && $(i, arguments[2]); + arguments.length > 2 && $(i, arguments[2])); var C = []; - return U(s, ee, { that: C }), L(i, 'errors', C), i; + return (U(s, ee, { that: C }), L(i, 'errors', C), i); }; x ? x(ie, Z) : C(ie, Z, { name: !0 }); var ae = (ie.prototype = j(Z.prototype, { @@ -30653,7 +30694,7 @@ var s = z(this), o = s.target, i = s.index++; - if (!o || i >= o.length) return (s.target = null), L(void 0, !0); + if (!o || i >= o.length) return ((s.target = null), L(void 0, !0)); switch (s.kind) { case 'keys': return L(i, !1); @@ -30681,16 +30722,16 @@ L = 7 !== new Error('e', { cause: 7 }).cause, exportGlobalErrorCauseWrapper = function (s, o) { var i = {}; - (i[s] = x(s, o, L)), u({ global: !0, constructor: !0, arity: 1, forced: L }, i); + ((i[s] = x(s, o, L)), u({ global: !0, constructor: !0, arity: 1, forced: L }, i)); }, exportWebAssemblyErrorCauseWrapper = function (s, o) { if (j && j[s]) { var i = {}; - (i[s] = x(C + '.' + s, o, L)), - u({ target: C, stat: !0, constructor: !0, arity: 1, forced: L }, i); + ((i[s] = x(C + '.' + s, o, L)), + u({ target: C, stat: !0, constructor: !0, arity: 1, forced: L }, i)); } }; - exportGlobalErrorCauseWrapper('Error', function (s) { + (exportGlobalErrorCauseWrapper('Error', function (s) { return function Error(o) { return w(s, this, arguments); }; @@ -30739,7 +30780,7 @@ return function RuntimeError(o) { return w(s, this, arguments); }; - }); + })); }, 79307: (s, o, i) => { 'use strict'; @@ -30791,13 +30832,13 @@ _ = i(45951), w = i(14840), x = i(93742); - for (var C in u) w(_[C], C), (x[C] = x.Array); + for (var C in u) (w(_[C], C), (x[C] = x.Array)); }, 694: (s, o, i) => { 'use strict'; i(91599); var u = i(37257); - i(12560), (s.exports = u); + (i(12560), (s.exports = u)); }, 19709: (s, o, i) => { 'use strict'; @@ -30815,11 +30856,11 @@ var o = u[s]; if (void 0 !== o) return o.exports; var _ = (u[s] = { id: s, loaded: !1, exports: {} }); - return i[s].call(_.exports, _, _.exports, __webpack_require__), (_.loaded = !0), _.exports; + return (i[s].call(_.exports, _, _.exports, __webpack_require__), (_.loaded = !0), _.exports); } - (__webpack_require__.n = (s) => { + ((__webpack_require__.n = (s) => { var o = s && s.__esModule ? () => s.default : () => s; - return __webpack_require__.d(o, { a: o }), o; + return (__webpack_require__.d(o, { a: o }), o); }), (o = Object.getPrototypeOf ? (s) => Object.getPrototypeOf(s) : (s) => s.__proto__), (__webpack_require__.t = function (i, u) { @@ -30834,7 +30875,7 @@ s = s || [null, o({}), o([]), o(o)]; for (var x = 2 & u && i; 'object' == typeof x && !~s.indexOf(x); x = o(x)) Object.getOwnPropertyNames(x).forEach((s) => (w[s] = () => i[s])); - return (w.default = () => i), __webpack_require__.d(_, w), _; + return ((w.default = () => i), __webpack_require__.d(_, w), _); }), (__webpack_require__.d = (s, o) => { for (var i in o) @@ -30852,19 +30893,19 @@ })()), (__webpack_require__.o = (s, o) => Object.prototype.hasOwnProperty.call(s, o)), (__webpack_require__.r = (s) => { - 'undefined' != typeof Symbol && + ('undefined' != typeof Symbol && Symbol.toStringTag && Object.defineProperty(s, Symbol.toStringTag, { value: 'Module' }), - Object.defineProperty(s, '__esModule', { value: !0 }); + Object.defineProperty(s, '__esModule', { value: !0 })); }), - (__webpack_require__.nmd = (s) => ((s.paths = []), s.children || (s.children = []), s)); + (__webpack_require__.nmd = (s) => ((s.paths = []), s.children || (s.children = []), s))); var _ = {}; return ( (() => { 'use strict'; __webpack_require__.d(_, { default: () => WI }); var s = {}; - __webpack_require__.r(s), + (__webpack_require__.r(s), __webpack_require__.d(s, { CLEAR: () => ot, CLEAR_BY: () => it, @@ -30880,9 +30921,9 @@ newSpecErrBatch: () => newSpecErrBatch, newThrownErr: () => newThrownErr, newThrownErrBatch: () => newThrownErrBatch - }); + })); var o = {}; - __webpack_require__.r(o), + (__webpack_require__.r(o), __webpack_require__.d(o, { AUTHORIZE: () => Nt, AUTHORIZE_OAUTH2: () => Lt, @@ -30910,9 +30951,9 @@ preAuthorizeImplicit: () => preAuthorizeImplicit, restoreAuthorization: () => restoreAuthorization, showDefinitions: () => showDefinitions - }); + })); var i = {}; - __webpack_require__.r(i), + (__webpack_require__.r(i), __webpack_require__.d(i, { authorized: () => Ht, definitionsForRequirements: () => definitionsForRequirements, @@ -30921,9 +30962,9 @@ getDefinitionsByNames: () => getDefinitionsByNames, isAuthorized: () => isAuthorized, shownDefinitions: () => Wt - }); + })); var u = {}; - __webpack_require__.r(u), + (__webpack_require__.r(u), __webpack_require__.d(u, { TOGGLE_CONFIGS: () => yn, UPDATE_CONFIGS: () => gn, @@ -30932,19 +30973,19 @@ loaded: () => actions_loaded, toggle: () => toggle, update: () => update - }); + })); var w = {}; - __webpack_require__.r(w), __webpack_require__.d(w, { get: () => get }); + (__webpack_require__.r(w), __webpack_require__.d(w, { get: () => get })); var x = {}; - __webpack_require__.r(x), __webpack_require__.d(x, { transform: () => transform }); + (__webpack_require__.r(x), __webpack_require__.d(x, { transform: () => transform })); var C = {}; - __webpack_require__.r(C), - __webpack_require__.d(C, { transform: () => parameter_oneof_transform }); + (__webpack_require__.r(C), + __webpack_require__.d(C, { transform: () => parameter_oneof_transform })); var j = {}; - __webpack_require__.r(j), - __webpack_require__.d(j, { allErrors: () => Mn, lastError: () => Tn }); + (__webpack_require__.r(j), + __webpack_require__.d(j, { allErrors: () => Mn, lastError: () => Tn })); var L = {}; - __webpack_require__.r(L), + (__webpack_require__.r(L), __webpack_require__.d(L, { SHOW: () => Fn, UPDATE_FILTER: () => Ln, @@ -30954,36 +30995,36 @@ show: () => actions_show, updateFilter: () => updateFilter, updateLayout: () => updateLayout - }); + })); var B = {}; - __webpack_require__.r(B), + (__webpack_require__.r(B), __webpack_require__.d(B, { current: () => current, currentFilter: () => currentFilter, isShown: () => isShown, showSummary: () => $n, whatMode: () => whatMode - }); + })); var $ = {}; - __webpack_require__.r($), - __webpack_require__.d($, { taggedOperations: () => taggedOperations }); + (__webpack_require__.r($), + __webpack_require__.d($, { taggedOperations: () => taggedOperations })); var V = {}; - __webpack_require__.r(V), + (__webpack_require__.r(V), __webpack_require__.d(V, { requestSnippetGenerator_curl_bash: () => requestSnippetGenerator_curl_bash, requestSnippetGenerator_curl_cmd: () => requestSnippetGenerator_curl_cmd, requestSnippetGenerator_curl_powershell: () => requestSnippetGenerator_curl_powershell - }); + })); var U = {}; - __webpack_require__.r(U), + (__webpack_require__.r(U), __webpack_require__.d(U, { getActiveLanguage: () => zn, getDefaultExpanded: () => Wn, getGenerators: () => Un, getSnippetGenerators: () => getSnippetGenerators - }); + })); var z = {}; - __webpack_require__.r(z), + (__webpack_require__.r(z), __webpack_require__.d(z, { JsonSchemaArrayItemFile: () => JsonSchemaArrayItemFile, JsonSchemaArrayItemText: () => JsonSchemaArrayItemText, @@ -30992,9 +31033,9 @@ JsonSchema_boolean: () => JsonSchema_boolean, JsonSchema_object: () => JsonSchema_object, JsonSchema_string: () => JsonSchema_string - }); + })); var Y = {}; - __webpack_require__.r(Y), + (__webpack_require__.r(Y), __webpack_require__.d(Y, { allowTryItOutFor: () => allowTryItOutFor, basePath: () => Ks, @@ -31054,9 +31095,9 @@ validateBeforeExecute: () => validateBeforeExecute, validationErrors: () => validationErrors, version: () => Ds - }); + })); var Z = {}; - __webpack_require__.r(Z), + (__webpack_require__.r(Z), __webpack_require__.d(Z, { CLEAR_REQUEST: () => wo, CLEAR_RESPONSE: () => Eo, @@ -31100,17 +31141,17 @@ updateSpec: () => updateSpec, updateUrl: () => updateUrl, validateParams: () => validateParams - }); + })); var ee = {}; - __webpack_require__.r(ee), + (__webpack_require__.r(ee), __webpack_require__.d(ee, { executeRequest: () => wrap_actions_executeRequest, updateJsonSpec: () => wrap_actions_updateJsonSpec, updateSpec: () => wrap_actions_updateSpec, validateParams: () => wrap_actions_validateParams - }); + })); var ie = {}; - __webpack_require__.r(ie), + (__webpack_require__.r(ie), __webpack_require__.d(ie, { JsonPatchError: () => Ro, _areEquals: () => _areEquals, @@ -31121,17 +31162,17 @@ getValueByPointer: () => getValueByPointer, validate: () => validate, validator: () => validator - }); + })); var ae = {}; - __webpack_require__.r(ae), + (__webpack_require__.r(ae), __webpack_require__.d(ae, { compare: () => compare, generate: () => generate, observe: () => observe, unobserve: () => unobserve - }); + })); var le = {}; - __webpack_require__.r(le), + (__webpack_require__.r(le), __webpack_require__.d(le, { hasElementSourceMap: () => hasElementSourceMap, includesClasses: () => includesClasses, @@ -31151,17 +31192,17 @@ isRefElement: () => Uu, isSourceMapElement: () => Hu, isStringElement: () => Ru - }); + })); var ce = {}; - __webpack_require__.r(ce), + (__webpack_require__.r(ce), __webpack_require__.d(ce, { isJSONReferenceElement: () => Nf, isJSONSchemaElement: () => Tf, isLinkDescriptionElement: () => Df, isMediaElement: () => Rf - }); + })); var pe = {}; - __webpack_require__.r(pe), + (__webpack_require__.r(pe), __webpack_require__.d(pe, { isBooleanJsonSchemaElement: () => isBooleanJsonSchemaElement, isCallbackElement: () => Im, @@ -31190,9 +31231,9 @@ isServerElement: () => Zm, isServerVariableElement: () => Qm, isServersElement: () => rg - }); + })); var de = {}; - __webpack_require__.r(de), + (__webpack_require__.r(de), __webpack_require__.d(de, { isBooleanJsonSchemaElement: () => predicates_isBooleanJsonSchemaElement, isCallbackElement: () => T_, @@ -31223,17 +31264,17 @@ isSecuritySchemeElement: () => tE, isServerElement: () => rE, isServerVariableElement: () => nE - }); + })); var fe = {}; - __webpack_require__.r(fe), + (__webpack_require__.r(fe), __webpack_require__.d(fe, { cookie: () => parameter_builders_cookie, header: () => parameter_builders_header, path: () => parameter_builders_path, query: () => parameter_builders_query - }); + })); var ye = {}; - __webpack_require__.r(ye), + (__webpack_require__.r(ye), __webpack_require__.d(ye, { Button: () => Button, Col: () => Col, @@ -31244,9 +31285,9 @@ Row: () => Row, Select: () => Select, TextArea: () => TextArea - }); + })); var be = {}; - __webpack_require__.r(be), + (__webpack_require__.r(be), __webpack_require__.d(be, { basePath: () => KO, consumes: () => HO, @@ -31258,11 +31299,12 @@ schemes: () => GO, securityDefinitions: () => zO, validOperationMethods: () => wrap_selectors_validOperationMethods - }); + })); var _e = {}; - __webpack_require__.r(_e), __webpack_require__.d(_e, { definitionsToAuthorize: () => YO }); + (__webpack_require__.r(_e), + __webpack_require__.d(_e, { definitionsToAuthorize: () => YO })); var we = {}; - __webpack_require__.r(we), + (__webpack_require__.r(we), __webpack_require__.d(we, { callbacksOperations: () => QO, findSchema: () => findSchema, @@ -31270,9 +31312,9 @@ isOAS30: () => selectors_isOAS30, isSwagger2: () => selectors_isSwagger2, servers: () => ZO - }); + })); var Se = {}; - __webpack_require__.r(Se), + (__webpack_require__.r(Se), __webpack_require__.d(Se, { CLEAR_REQUEST_BODY_VALIDATE_ERROR: () => bA, CLEAR_REQUEST_BODY_VALUE: () => _A, @@ -31297,9 +31339,9 @@ setRetainRequestBodyValueFlag: () => setRetainRequestBodyValueFlag, setSelectedServer: () => setSelectedServer, setServerVariableValue: () => setServerVariableValue - }); + })); var xe = {}; - __webpack_require__.r(xe), + (__webpack_require__.r(xe), __webpack_require__.d(xe, { activeExamplesMember: () => jA, hasUserEditedBody: () => CA, @@ -31317,7 +31359,7 @@ validOperationMethods: () => DA, validateBeforeExecute: () => RA, validateShallowRequired: () => validateShallowRequired - }); + })); var Pe = __webpack_require__(96540); function formatProdErrorMessage(s) { return `Minified Redux error #${s}; visit https://redux.js.org/Errors?code=${s} for the full message or use the non-minified dev environment for full errors. `; @@ -31374,7 +31416,7 @@ function unsubscribe() { if (o) { if (j) throw new Error(formatProdErrorMessage(6)); - (o = !1), ensureCanMutateNextListeners(), x.delete(i), (w = null); + ((o = !1), ensureCanMutateNextListeners(), x.delete(i), (w = null)); } } ); @@ -31385,7 +31427,7 @@ if ('string' != typeof s.type) throw new Error(formatProdErrorMessage(17)); if (j) throw new Error(formatProdErrorMessage(9)); try { - (j = !0), (_ = u(_, s)); + ((j = !0), (_ = u(_, s))); } finally { j = !1; } @@ -31403,7 +31445,7 @@ getState, replaceReducer: function replaceReducer(s) { if ('function' != typeof s) throw new Error(formatProdErrorMessage(10)); - (u = s), dispatch({ type: Re.REPLACE }); + ((u = s), dispatch({ type: Re.REPLACE })); }, [Te]: function observable() { const s = subscribe; @@ -31568,11 +31610,11 @@ for (let _ of s.entries()) if (o[_[0]] || (u[_[0]] && u[_[0]].containsMultiple)) { if (!u[_[0]]) { - (u[_[0]] = { containsMultiple: !0, length: 1 }), + ((u[_[0]] = { containsMultiple: !0, length: 1 }), (o[`${_[0]}${i}${u[_[0]].length}`] = o[_[0]]), - delete o[_[0]]; + delete o[_[0]]); } - (u[_[0]].length += 1), (o[`${_[0]}${i}${u[_[0]].length}`] = _[1]); + ((u[_[0]].length += 1), (o[`${_[0]}${i}${u[_[0]].length}`] = _[1])); } else o[_[0]] = _[1]; return o; })(s); @@ -31602,7 +31644,7 @@ function objReduce(s, o) { return Object.keys(s).reduce((i, u) => { let _ = o(s[u], u); - return _ && 'object' == typeof _ && Object.assign(i, _), i; + return (_ && 'object' == typeof _ && Object.assign(i, _), i); }, {}); } function systemThunkMiddleware(s) { @@ -31630,7 +31672,7 @@ ae = null != s, le = ie || (ae && 'array' === B) || !(!ie && !ae), ce = x && null === s; - if (ie && !ae && !ce && !u && !B) return w.push('Required field is not provided'), w; + if (ie && !ae && !ce && !u && !B) return (w.push('Required field is not provided'), w); if (ce || !B || !le) return []; let pe = 'string' === B && s, de = 'array' === B && Array.isArray(s) && s.length, @@ -31647,16 +31689,16 @@ 'object' === B && 'object' == typeof s && null !== s, 'object' === B && 'string' == typeof s && s ].some((s) => !!s); - if (ie && !ye && !u) return w.push('Required field is not provided'), w; + if (ie && !ye && !u) return (w.push('Required field is not provided'), w); if ('object' === B && (null === _ || 'application/json' === _)) { let i = s; if ('string' == typeof s) try { i = JSON.parse(s); } catch (s) { - return w.push('Parameter string value must be valid JSON'), w; + return (w.push('Parameter string value must be valid JSON'), w); } - o && + (o && o.has('required') && isFunc(C.isList) && C.isList() && @@ -31668,7 +31710,7 @@ o.get('properties').forEach((s, o) => { const x = validateValueBySchema(i[o], s, !1, u, _); w.push(...x.map((s) => ({ propKey: o, error: s }))); - }); + })); } if (ee) { let o = ((s, o) => { @@ -31797,7 +31839,10 @@ } const utils_btoa = (s) => { let o; - return (o = s instanceof Ot ? s : Ot.from(s.toString(), 'utf-8')), o.toString('base64'); + return ( + (o = s instanceof Ot ? s : Ot.from(s.toString(), 'utf-8')), + o.toString('base64') + ); }, It = { operationsSorter: { @@ -31891,7 +31936,7 @@ }; const w = { getState: _.getState, dispatch: (s, ...o) => dispatch(s, ...o) }, x = s.map((s) => s(w)); - return (dispatch = compose(...x)(_.dispatch)), { ..._, dispatch }; + return ((dispatch = compose(...x)(_.dispatch)), { ..._, dispatch }); }; })(...u) ) @@ -31899,7 +31944,7 @@ } class Store { constructor(s = {}) { - We()( + (We()( this, { state: {}, @@ -31915,20 +31960,20 @@ return createStoreWithMiddleware(s, o, i); })(idFn, (0, qe.fromJS)(this.state), this.getSystem)), this.buildSystem(!1), - this.register(this.plugins); + this.register(this.plugins)); } getStore() { return this.store; } register(s, o = !0) { var i = combinePlugins(s, this.getSystem()); - systemExtend(this.system, i), o && this.buildSystem(); + (systemExtend(this.system, i), o && this.buildSystem()); callAfterLoad.call(this.system, s, this.getSystem()) && this.buildSystem(); } buildSystem(s = !0) { let o = this.getStore().dispatch, i = this.getStore().getState; - (this.boundSystem = Object.assign( + ((this.boundSystem = Object.assign( {}, this.getRootInjects(), this.getWrappedAndBoundActions(o), @@ -31937,7 +31982,7 @@ this.getFn(), this.getConfigs() )), - s && this.rebuildReducer(); + s && this.rebuildReducer()); } _getSystem() { return this.boundSystem; @@ -32081,7 +32126,7 @@ let _ = [u.slice(0, -9)]; return objMap(i, (i) => (...u) => { let w = wrapWithTryCatch(i).apply(null, [s().getIn(_), ...u]); - return 'function' == typeof w && (w = wrapWithTryCatch(w)(o())), w; + return ('function' == typeof w && (w = wrapWithTryCatch(w)(o())), w); }); }); } @@ -32166,7 +32211,7 @@ if (isObject(_)) for (let i in _) { let u = _[i]; - Array.isArray(u) || ((u = [u]), (_[i] = u)), + (Array.isArray(u) || ((u = [u]), (_[i] = u)), o && o.statePlugins && o.statePlugins[s] && @@ -32174,12 +32219,12 @@ o.statePlugins[s].wrapActions[i] && (o.statePlugins[s].wrapActions[i] = _[i].concat( o.statePlugins[s].wrapActions[i] - )); + ))); } if (isObject(w)) for (let i in w) { let u = w[i]; - Array.isArray(u) || ((u = [u]), (w[i] = u)), + (Array.isArray(u) || ((u = [u]), (w[i] = u)), o && o.statePlugins && o.statePlugins[s] && @@ -32187,7 +32232,7 @@ o.statePlugins[s].wrapSelectors[i] && (o.statePlugins[s].wrapSelectors[i] = w[i].concat( o.statePlugins[s].wrapSelectors[i] - )); + ))); } } return We()(s, o); @@ -32199,7 +32244,7 @@ try { return s.call(this, ...i); } catch (s) { - return o && console.error(s), null; + return (o && console.error(s), null); } }; } @@ -32222,7 +32267,7 @@ const authorizeWithPersistOption = (s) => ({ authActions: o }) => { - o.authorize(s), o.persistAuthorizationIfNeeded(); + (o.authorize(s), o.persistAuthorizationIfNeeded()); }; function logout(s) { return { type: Rt, payload: s }; @@ -32230,7 +32275,7 @@ const logoutWithPersistOption = (s) => ({ authActions: o }) => { - o.logout(s), o.persistAuthorizationIfNeeded(); + (o.logout(s), o.persistAuthorizationIfNeeded()); }, preAuthorizeImplicit = (s) => @@ -32238,7 +32283,7 @@ let { auth: u, token: _, isValid: w } = s, { schema: x, name: C } = u, j = x.get('flow'); - delete at.swaggerUIRedirectOauth2, + (delete at.swaggerUIRedirectOauth2, 'accessCode' === j || w || i.newAuthErr({ @@ -32255,7 +32300,7 @@ level: 'error', message: JSON.stringify(_) }) - : o.authorizeOauth2WithPersistOption({ auth: u, token: _ }); + : o.authorizeOauth2WithPersistOption({ auth: u, token: _ })); }; function authorizeOauth2(s) { return { type: Lt, payload: s }; @@ -32263,7 +32308,7 @@ const authorizeOauth2WithPersistOption = (s) => ({ authActions: o }) => { - o.authorizeOauth2(s), o.persistAuthorizationIfNeeded(); + (o.authorizeOauth2(s), o.persistAuthorizationIfNeeded()); }, authorizePassword = (s) => @@ -32419,8 +32464,8 @@ const i = s.response.data; try { const s = 'string' == typeof i ? JSON.parse(i) : i; - s.error && (o += `, error: ${s.error}`), - s.error_description && (o += `, description: ${s.error_description}`); + (s.error && (o += `, error: ${s.error}`), + s.error_description && (o += `, description: ${s.error_description}`)); } catch (s) {} } _.newAuthErr({ authId: V, level: 'error', source: 'auth', message: o }); @@ -32440,7 +32485,7 @@ localStorage.setItem('authorized', JSON.stringify(i)); }, authPopup = (s, o) => () => { - (at.swaggerUIRedirectOauth2 = o), at.open(s); + ((at.swaggerUIRedirectOauth2 = o), at.open(s)); }, $t = { [Tt]: (s, { payload: o }) => s.set('showDefinitions', o), @@ -32455,11 +32500,11 @@ else if ('basic' === _) { let s = i.getIn(['value', 'username']), _ = i.getIn(['value', 'password']); - (u = u.setIn([o, 'value'], { + ((u = u.setIn([o, 'value'], { username: s, header: 'Basic ' + utils_btoa(s + ':' + _) })), - (u = u.setIn([o, 'schema'], i.get('schema'))); + (u = u.setIn([o, 'schema'], i.get('schema')))); } }), s.set('authorized', u) @@ -32468,9 +32513,9 @@ [Lt]: (s, { payload: o }) => { let i, { auth: u, token: _ } = o; - (u.token = Object.assign({}, _)), (i = (0, qe.fromJS)(u)); + ((u.token = Object.assign({}, _)), (i = (0, qe.fromJS)(u))); let w = s.get('authorized') || (0, qe.Map)(); - return (w = w.set(i.get('name'), i)), s.set('authorized', w); + return ((w = w.set(i.get('name'), i)), s.set('authorized', w)); }, [Rt]: (s, { payload: o }) => { let i = s.get('authorized').withMutations((s) => { @@ -32509,7 +32554,7 @@ o ); } - Symbol(), Object.getPrototypeOf({}); + (Symbol(), Object.getPrototypeOf({})); var Vt = 'undefined' != typeof WeakRef ? WeakRef @@ -32551,11 +32596,11 @@ null != s && u(s, j) && ((j = s), 0 !== w && w--); _ = ('object' == typeof j && null !== j) || 'function' == typeof j ? new Vt(j) : j; } - return (C.s = 1), (C.v = j), j; + return ((C.s = 1), (C.v = j), j); } return ( (memoized.clearCache = () => { - (i = { s: 0, v: void 0, o: null, p: null }), memoized.resetResultsCount(); + ((i = { s: 0, v: void 0, o: null, p: null }), memoized.resetResultsCount()); }), (memoized.resultsCount = () => w), (memoized.resetResultsCount = () => { @@ -32572,11 +32617,11 @@ _ = 0, w = {}, x = s.pop(); - 'object' == typeof x && ((w = x), (x = s.pop())), + ('object' == typeof x && ((w = x), (x = s.pop())), assertIsFunction( x, `createSelector expects an output function after the inputs, but received: [${typeof x}]` - ); + )); const C = { ...i, ...w }, { memoize: j, @@ -32590,7 +32635,7 @@ Y = getDependencies(s), Z = j( function recomputationWrapper() { - return u++, x.apply(null, arguments); + return (u++, x.apply(null, arguments)); }, ...U ); @@ -32603,7 +32648,7 @@ for (let _ = 0; _ < u; _++) i.push(s[_].apply(null, o)); return i; })(Y, arguments); - return (o = Z.apply(null, s)), o; + return ((o = Z.apply(null, s)), o); }, ...z ); @@ -32625,7 +32670,8 @@ }); }; return ( - Object.assign(createSelector2, { withTypes: () => createSelector2 }), createSelector2 + Object.assign(createSelector2, { withTypes: () => createSelector2 }), + createSelector2 ); } var Ut = createSelectorCreator(weakMapMemoize), @@ -32654,7 +32700,7 @@ return ( o.entrySeq().forEach(([s, o]) => { let u = (0, qe.Map)(); - (u = u.set(s, o)), (i = i.push(u)); + ((u = u.set(s, o)), (i = i.push(u))); }), i ); @@ -32670,19 +32716,19 @@ return ( o.valueSeq().forEach((s) => { let o = (0, qe.Map)(); - s.entrySeq().forEach(([s, u]) => { + (s.entrySeq().forEach(([s, u]) => { let _, w = i.get(s); - 'oauth2' === w.get('type') && + ('oauth2' === w.get('type') && u.size && ((_ = w.get('scopes')), _.keySeq().forEach((s) => { u.contains(s) || (_ = _.delete(s)); }), (w = w.set('allowedScopes', _))), - (o = o.set(s, w)); + (o = o.set(s, w))); }), - (u = u.push(o)); + (u = u.push(o))); }), u ); @@ -32806,10 +32852,10 @@ function auth() { return { afterLoad(s) { - (this.rootInjects = this.rootInjects || {}), + ((this.rootInjects = this.rootInjects || {}), (this.rootInjects.initOAuth = s.authActions.configureAuth), (this.rootInjects.preauthorizeApiKey = preauthorizeApiKey.bind(null, s)), - (this.rootInjects.preauthorizeBasic = preauthorizeBasic.bind(null, s)); + (this.rootInjects.preauthorizeBasic = preauthorizeBasic.bind(null, s))); }, components: { LockAuthIcon: Xt, @@ -32887,20 +32933,20 @@ : u; } function YAMLException$1(s, o) { - Error.call(this), + (Error.call(this), (this.name = 'YAMLException'), (this.reason = s), (this.mark = o), (this.message = formatError(this, !1)), Error.captureStackTrace ? Error.captureStackTrace(this, this.constructor) - : (this.stack = new Error().stack || ''); + : (this.stack = new Error().stack || '')); } - (YAMLException$1.prototype = Object.create(Error.prototype)), + ((YAMLException$1.prototype = Object.create(Error.prototype)), (YAMLException$1.prototype.constructor = YAMLException$1), (YAMLException$1.prototype.toString = function toString(s) { return this.name + ': ' + formatError(this, s); - }); + })); var rr = YAMLException$1; function getLine(s, o, i, u, _) { var w = '', @@ -32917,14 +32963,14 @@ } var nr = function makeSnippet(s, o) { if (((o = Object.create(o || null)), !s.buffer)) return null; - o.maxLength || (o.maxLength = 79), + (o.maxLength || (o.maxLength = 79), 'number' != typeof o.indent && (o.indent = 1), 'number' != typeof o.linesBefore && (o.linesBefore = 3), - 'number' != typeof o.linesAfter && (o.linesAfter = 2); + 'number' != typeof o.linesAfter && (o.linesAfter = 2)); for (var i, u = /\r?\n|\r|\0/g, _ = [0], w = [], x = -1; (i = u.exec(s.buffer)); ) - w.push(i.index), + (w.push(i.index), _.push(i.index + i[0].length), - s.position <= i.index && x < 0 && (x = _.length - 2); + s.position <= i.index && x < 0 && (x = _.length - 2)); x < 0 && (x = _.length - 1); var C, j, @@ -32932,14 +32978,14 @@ B = Math.min(s.line + o.linesAfter, w.length).toString().length, $ = o.maxLength - (o.indent + B + 3); for (C = 1; C <= o.linesBefore && !(x - C < 0); C++) - (j = getLine(s.buffer, _[x - C], w[x - C], s.position - (_[x] - _[x - C]), $)), + ((j = getLine(s.buffer, _[x - C], w[x - C], s.position - (_[x] - _[x - C]), $)), (L = tr.repeat(' ', o.indent) + padStart((s.line - C + 1).toString(), B) + ' | ' + j.str + '\n' + - L); + L)); for ( j = getLine(s.buffer, _[x], w[x], s.position, $), L += @@ -32953,13 +32999,13 @@ C <= o.linesAfter && !(x + C >= w.length); C++ ) - (j = getLine(s.buffer, _[x + C], w[x + C], s.position - (_[x] - _[x + C]), $)), + ((j = getLine(s.buffer, _[x + C], w[x + C], s.position - (_[x] - _[x + C]), $)), (L += tr.repeat(' ', o.indent) + padStart((s.line + C + 1).toString(), B) + ' | ' + j.str + - '\n'); + '\n')); return L.replace(/\n$/, ''); }, sr = [ @@ -33026,10 +33072,10 @@ return ( s[o].forEach(function (s) { var o = i.length; - i.forEach(function (i, u) { + (i.forEach(function (i, u) { i.tag === s.tag && i.kind === s.kind && i.multi === s.multi && (o = u); }), - (i[o] = s); + (i[o] = s)); }), i ); @@ -33047,9 +33093,9 @@ throw new rr( 'Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })' ); - s.implicit && (o = o.concat(s.implicit)), s.explicit && (i = i.concat(s.explicit)); + (s.implicit && (o = o.concat(s.implicit)), s.explicit && (i = i.concat(s.explicit))); } - o.forEach(function (s) { + (o.forEach(function (s) { if (!(s instanceof ar)) throw new rr( 'Specified list of YAML types (or a single Type object) contains a non-Type object.' @@ -33068,7 +33114,7 @@ throw new rr( 'Specified list of YAML types (or a single Type object) contains a non-Type object.' ); - }); + })); var u = Object.create(Schema$1.prototype); return ( (u.implicit = (this.implicit || []).concat(o)), @@ -33345,7 +33391,7 @@ return '-.Inf'; } else if (tr.isNegativeZero(s)) return '-0.0'; - return (i = s.toString(10)), vr.test(i) ? i.replace('e', '.e') : i; + return ((i = s.toString(10)), vr.test(i) ? i.replace('e', '.e') : i); }, defaultStyle: 'lowercase' }), @@ -33423,10 +33469,10 @@ x = 0, C = []; for (o = 0; o < _; o++) - o % 4 == 0 && + (o % 4 == 0 && o && (C.push((x >> 16) & 255), C.push((x >> 8) & 255), C.push(255 & x)), - (x = (x << 6) | w.indexOf(u.charAt(o))); + (x = (x << 6) | w.indexOf(u.charAt(o)))); return ( 0 === (i = (_ % 4) * 6) ? (C.push((x >> 16) & 255), C.push((x >> 8) & 255), C.push(255 & x)) @@ -33447,13 +33493,13 @@ w = s.length, x = Cr; for (o = 0; o < w; o++) - o % 3 == 0 && + (o % 3 == 0 && o && ((u += x[(_ >> 18) & 63]), (u += x[(_ >> 12) & 63]), (u += x[(_ >> 6) & 63]), (u += x[63 & _])), - (_ = (_ << 8) + s[o]); + (_ = (_ << 8) + s[o])); return ( 0 === (i = w % 3) ? ((u += x[(_ >> 18) & 63]), @@ -33531,7 +33577,7 @@ w, x = s; for (w = new Array(x.length), o = 0, i = x.length; o < i; o += 1) - (u = x[o]), (_ = Object.keys(u)), (w[o] = [_[0], u[_[0]]]); + ((u = x[o]), (_ = Object.keys(u)), (w[o] = [_[0], u[_[0]]])); return w; } }), @@ -33619,9 +33665,9 @@ : String.fromCharCode(55296 + ((s - 65536) >> 10), 56320 + ((s - 65536) & 1023)); } for (var Vr = new Array(256), Ur = new Array(256), zr = 0; zr < 256; zr++) - (Vr[zr] = simpleEscapeSequence(zr) ? 1 : 0), (Ur[zr] = simpleEscapeSequence(zr)); + ((Vr[zr] = simpleEscapeSequence(zr) ? 1 : 0), (Ur[zr] = simpleEscapeSequence(zr))); function State$1(s, o) { - (this.input = s), + ((this.input = s), (this.filename = o.filename || null), (this.schema = o.schema || Rr), (this.onWarning = o.onWarning || null), @@ -33636,7 +33682,7 @@ (this.lineStart = 0), (this.lineIndent = 0), (this.firstTabInLine = -1), - (this.documents = []); + (this.documents = [])); } function generateError(s, o) { var i = { @@ -33646,7 +33692,7 @@ line: s.line, column: s.position - s.lineStart }; - return (i.snippet = nr(i)), new rr(o, i); + return ((i.snippet = nr(i)), new rr(o, i)); } function throwError(s, o) { throw generateError(s, o); @@ -33657,7 +33703,7 @@ var Wr = { YAML: function handleYamlDirective(s, o, i) { var u, _, w; - null !== s.version && throwError(s, 'duplication of %YAML directive'), + (null !== s.version && throwError(s, 'duplication of %YAML directive'), 1 !== i.length && throwError(s, 'YAML directive accepts exactly one argument'), null === (u = /^([0-9]+)\.([0-9]+)$/.exec(i[0])) && throwError(s, 'ill-formed argument of the YAML directive'), @@ -33666,11 +33712,11 @@ 1 !== _ && throwError(s, 'unacceptable YAML version of the document'), (s.version = i[0]), (s.checkLineBreaks = w < 2), - 1 !== w && 2 !== w && throwWarning(s, 'unsupported YAML version of the document'); + 1 !== w && 2 !== w && throwWarning(s, 'unsupported YAML version of the document')); }, TAG: function handleTagDirective(s, o, i) { var u, _; - 2 !== i.length && throwError(s, 'TAG directive accepts exactly two arguments'), + (2 !== i.length && throwError(s, 'TAG directive accepts exactly two arguments'), (u = i[0]), (_ = i[1]), qr.test(u) || @@ -33678,7 +33724,7 @@ Dr.call(s.tagMap, u) && throwError(s, 'there is a previously declared suffix for "' + u + '" tag handle'), $r.test(_) || - throwError(s, 'ill-formed tag prefix (second argument) of the TAG directive'); + throwError(s, 'ill-formed tag prefix (second argument) of the TAG directive')); try { _ = decodeURIComponent(_); } catch (o) { @@ -33709,16 +33755,16 @@ x < C; x += 1 ) - (w = _[x]), Dr.call(o, w) || ((o[w] = i[w]), (u[w] = !0)); + ((w = _[x]), Dr.call(o, w) || ((o[w] = i[w]), (u[w] = !0))); } function storeMappingPair(s, o, i, u, _, w, x, C, j) { var L, B; if (Array.isArray(_)) for (L = 0, B = (_ = Array.prototype.slice.call(_)).length; L < B; L += 1) - Array.isArray(_[L]) && throwError(s, 'nested arrays are not supported inside keys'), + (Array.isArray(_[L]) && throwError(s, 'nested arrays are not supported inside keys'), 'object' == typeof _ && '[object Object]' === _class(_[L]) && - (_[L] = '[object Object]'); + (_[L] = '[object Object]')); if ( ('object' == typeof _ && '[object Object]' === _class(_) && (_ = '[object Object]'), (_ = String(_)), @@ -33729,7 +33775,7 @@ for (L = 0, B = w.length; L < B; L += 1) mergeMappings(s, o, w[L], i); else mergeMappings(s, o, w, i); else - s.json || + (s.json || Dr.call(i, _) || !Dr.call(o, _) || ((s.line = x || s.line), @@ -33744,25 +33790,25 @@ value: w }) : (o[_] = w), - delete i[_]; + delete i[_]); return o; } function readLineBreak(s) { var o; - 10 === (o = s.input.charCodeAt(s.position)) + (10 === (o = s.input.charCodeAt(s.position)) ? s.position++ : 13 === o ? (s.position++, 10 === s.input.charCodeAt(s.position) && s.position++) : throwError(s, 'a line break is expected'), (s.line += 1), (s.lineStart = s.position), - (s.firstTabInLine = -1); + (s.firstTabInLine = -1)); } function skipSeparationSpace(s, o, i) { for (var u = 0, _ = s.input.charCodeAt(s.position); 0 !== _; ) { for (; is_WHITE_SPACE(_); ) - 9 === _ && -1 === s.firstTabInLine && (s.firstTabInLine = s.position), - (_ = s.input.charCodeAt(++s.position)); + (9 === _ && -1 === s.firstTabInLine && (s.firstTabInLine = s.position), + (_ = s.input.charCodeAt(++s.position))); if (o && 35 === _) do { _ = s.input.charCodeAt(++s.position); @@ -33771,12 +33817,12 @@ for ( readLineBreak(s), _ = s.input.charCodeAt(s.position), u++, s.lineIndent = 0; 32 === _; - ) - s.lineIndent++, (_ = s.input.charCodeAt(++s.position)); + (s.lineIndent++, (_ = s.input.charCodeAt(++s.position))); } return ( - -1 !== i && 0 !== u && s.lineIndent < i && throwWarning(s, 'deficient indentation'), u + -1 !== i && 0 !== u && s.lineIndent < i && throwWarning(s, 'deficient indentation'), + u ); } function testDocumentSeparator(s) { @@ -33808,10 +33854,9 @@ throwError(s, 'tab characters must not be used in indentation')), 45 === u) && is_WS_OR_EOL(s.input.charCodeAt(s.position + 1)); - ) if (((C = !0), s.position++, skipSeparationSpace(s, !0, -1) && s.lineIndent <= o)) - x.push(null), (u = s.input.charCodeAt(s.position)); + (x.push(null), (u = s.input.charCodeAt(s.position))); else if ( ((i = s.line), composeNode(s, o, 3, !1, !0), @@ -33850,16 +33895,16 @@ : throwError(s, 'unexpected end of the stream within a verbatim tag'); } else { for (; 0 !== _ && !is_WS_OR_EOL(_); ) - 33 === _ && + (33 === _ && (x ? throwError(s, 'tag suffix cannot contain exclamation marks') : ((i = s.input.slice(o - 1, s.position + 1)), qr.test(i) || throwError(s, 'named tag handle cannot contain such characters'), (x = !0), (o = s.position + 1))), - (_ = s.input.charCodeAt(++s.position)); - (u = s.input.slice(o, s.position)), - Fr.test(u) && throwError(s, 'tag suffix cannot contain flow indicator characters'); + (_ = s.input.charCodeAt(++s.position))); + ((u = s.input.slice(o, s.position)), + Fr.test(u) && throwError(s, 'tag suffix cannot contain flow indicator characters')); } u && !$r.test(u) && throwError(s, 'tag name cannot contain such characters: ' + u); try { @@ -33888,7 +33933,6 @@ i = s.input.charCodeAt(++s.position), o = s.position; 0 !== i && !is_WS_OR_EOL(i) && !is_FLOW_INDICATOR(i); - ) i = s.input.charCodeAt(++s.position); return ( @@ -33968,7 +34012,6 @@ null !== s.anchor && (s.anchorMap[s.anchor] = V), L = s.input.charCodeAt(s.position); 0 !== L; - ) { if ( (ee || @@ -33990,7 +34033,7 @@ for (L = s.input.charCodeAt(s.position); is_WHITE_SPACE(L); ) L = s.input.charCodeAt(++s.position); if (58 === L) - is_WS_OR_EOL((L = s.input.charCodeAt(++s.position))) || + (is_WS_OR_EOL((L = s.input.charCodeAt(++s.position))) || throwError( s, 'a whitespace character is expected after the key-value separator within a block mapping' @@ -34002,23 +34045,23 @@ (ee = !1), (_ = !1), (z = s.tag), - (Y = s.result); + (Y = s.result)); else { - if (!ie) return (s.tag = B), (s.anchor = $), !0; + if (!ie) return ((s.tag = B), (s.anchor = $), !0); throwError( s, 'can not read an implicit mapping pair; a colon is missed' ); } } else { - if (!ie) return (s.tag = B), (s.anchor = $), !0; + if (!ie) return ((s.tag = B), (s.anchor = $), !0); throwError( s, 'can not read a block mapping entry; a multiline key may not be an implicit key' ); } } else - 63 === L + (63 === L ? (ee && (storeMappingPair(s, V, U, z, Y, null, x, C, j), (z = Y = Z = null)), @@ -34032,7 +34075,7 @@ 'incomplete explicit mapping pair; a key node is missed; or followed by a non-tabulated empty line' ), (s.position += 1), - (L = u); + (L = u)); if ( ((s.line === w || s.lineIndent > o) && (ee && ((x = s.line), (C = s.lineStart), (j = s.position)), @@ -34069,16 +34112,15 @@ Y = s.tag, Z = s.anchor, ee = Object.create(null); - if (91 === (U = s.input.charCodeAt(s.position))) (x = 93), (L = !1), (w = []); + if (91 === (U = s.input.charCodeAt(s.position))) ((x = 93), (L = !1), (w = [])); else { if (123 !== U) return !1; - (x = 125), (L = !0), (w = {}); + ((x = 125), (L = !0), (w = {})); } for ( null !== s.anchor && (s.anchorMap[s.anchor] = w), U = s.input.charCodeAt(++s.position); 0 !== U; - ) { if ( (skipSeparationSpace(s, !0, o), (U = s.input.charCodeAt(s.position)) === x) @@ -34091,7 +34133,7 @@ (s.result = w), !0 ); - z + (z ? 44 === U && throwError(s, "expected the node content, but found ','") : throwError(s, 'missed comma between flow collection entries'), (V = null), @@ -34122,7 +34164,7 @@ skipSeparationSpace(s, !0, o), 44 === (U = s.input.charCodeAt(s.position)) ? ((z = !0), (U = s.input.charCodeAt(++s.position))) - : (z = !1); + : (z = !1)); } throwError(s, 'unexpected end of the stream within a flow collection'); })(s, V) @@ -34174,9 +34216,8 @@ for ( readLineBreak(s), s.lineIndent = 0, w = s.input.charCodeAt(s.position); (!L || s.lineIndent < B) && 32 === w; - ) - s.lineIndent++, (w = s.input.charCodeAt(++s.position)); + (s.lineIndent++, (w = s.input.charCodeAt(++s.position))); if ((!L && s.lineIndent > B && (B = s.lineIndent), is_EOL(w))) $++; else { if (s.lineIndent < B) { @@ -34200,7 +34241,6 @@ $ = 0, i = s.position; !is_EOL(w) && 0 !== w; - ) w = s.input.charCodeAt(++s.position); captureSegment(s, i, s.position, !1); @@ -34214,7 +34254,6 @@ for ( s.kind = 'scalar', s.result = '', s.position++, u = _ = s.position; 0 !== (i = s.input.charCodeAt(s.position)); - ) if (39 === i) { if ( @@ -34222,7 +34261,7 @@ 39 !== (i = s.input.charCodeAt(++s.position))) ) return !0; - (u = s.position), s.position++, (_ = s.position); + ((u = s.position), s.position++, (_ = s.position)); } else is_EOL(i) ? (captureSegment(s, u, _, !0), @@ -34242,16 +34281,16 @@ for ( s.kind = 'scalar', s.result = '', s.position++, i = u = s.position; 0 !== (C = s.input.charCodeAt(s.position)); - ) { - if (34 === C) return captureSegment(s, i, s.position, !0), s.position++, !0; + if (34 === C) + return (captureSegment(s, i, s.position, !0), s.position++, !0); if (92 === C) { if ( (captureSegment(s, i, s.position, !0), is_EOL((C = s.input.charCodeAt(++s.position)))) ) skipSeparationSpace(s, !1, o); - else if (C < 256 && Vr[C]) (s.result += Ur[C]), s.position++; + else if (C < 256 && Vr[C]) ((s.result += Ur[C]), s.position++); else if ( (x = 120 === (j = C) ? 2 : 117 === j ? 4 : 85 === j ? 8 : 0) > 0 ) { @@ -34259,7 +34298,7 @@ (x = fromHexCode((C = s.input.charCodeAt(++s.position)))) >= 0 ? (w = (w << 4) + x) : throwError(s, 'expected hexadecimal character'); - (s.result += charFromCodepoint(w)), s.position++; + ((s.result += charFromCodepoint(w)), s.position++); } else throwError(s, 'unknown escape sequence'); i = u = s.position; } else @@ -34283,7 +34322,6 @@ for ( u = s.input.charCodeAt(++s.position), o = s.position; 0 !== u && !is_WS_OR_EOL(u) && !is_FLOW_INDICATOR(u); - ) u = s.input.charCodeAt(++s.position); return ( @@ -34336,7 +34374,6 @@ for ( s.kind = 'scalar', s.result = '', _ = w = s.position, x = !1; 0 !== B; - ) { if (58 === B) { if ( @@ -34360,23 +34397,23 @@ skipSeparationSpace(s, !1, -1), s.lineIndent >= o) ) { - (x = !0), (B = s.input.charCodeAt(s.position)); + ((x = !0), (B = s.input.charCodeAt(s.position))); continue; } - (s.position = w), + ((s.position = w), (s.line = C), (s.lineStart = j), - (s.lineIndent = L); + (s.lineIndent = L)); break; } } - x && + (x && (captureSegment(s, _, w, !1), writeFoldedLines(s, s.line - C), (_ = w = s.position), (x = !1)), is_WHITE_SPACE(B) || (w = s.position + 1), - (B = s.input.charCodeAt(++s.position)); + (B = s.input.charCodeAt(++s.position))); } return ( captureSegment(s, _, w, !1), @@ -34405,9 +34442,9 @@ j += 1 ) if (($ = s.implicitTypes[j]).resolve(s.result)) { - (s.result = $.construct(s.result)), + ((s.result = $.construct(s.result)), (s.tag = $.tag), - null !== s.anchor && (s.anchorMap[s.anchor] = s.result); + null !== s.anchor && (s.anchorMap[s.anchor] = s.result)); break; } } else if ('!' !== s.tag) { @@ -34423,7 +34460,7 @@ $ = B[j]; break; } - $ || throwError(s, 'unknown tag !<' + s.tag + '>'), + ($ || throwError(s, 'unknown tag !<' + s.tag + '>'), null !== s.result && $.kind !== s.kind && throwError( @@ -34439,10 +34476,11 @@ $.resolve(s.result, s.tag) ? ((s.result = $.construct(s.result, s.tag)), null !== s.anchor && (s.anchorMap[s.anchor] = s.result)) - : throwError(s, 'cannot resolve a node with !<' + s.tag + '> explicit tag'); + : throwError(s, 'cannot resolve a node with !<' + s.tag + '> explicit tag')); } return ( - null !== s.listener && s.listener('close', s), null !== s.tag || null !== s.anchor || Z + null !== s.listener && s.listener('close', s), + null !== s.tag || null !== s.anchor || Z ); } function readDocument(s) { @@ -34461,12 +34499,10 @@ (skipSeparationSpace(s, !0, -1), (_ = s.input.charCodeAt(s.position)), !(s.lineIndent > 0 || 37 !== _)); - ) { for ( x = !0, _ = s.input.charCodeAt(++s.position), o = s.position; 0 !== _ && !is_WS_OR_EOL(_); - ) _ = s.input.charCodeAt(++s.position); for ( @@ -34474,7 +34510,6 @@ (i = s.input.slice(o, s.position)).length < 1 && throwError(s, 'directive name must not be less than one character in length'); 0 !== _; - ) { for (; is_WHITE_SPACE(_); ) _ = s.input.charCodeAt(++s.position); if (35 === _) { @@ -34488,12 +34523,12 @@ _ = s.input.charCodeAt(++s.position); u.push(s.input.slice(o, s.position)); } - 0 !== _ && readLineBreak(s), + (0 !== _ && readLineBreak(s), Dr.call(Wr, i) ? Wr[i](s, i, u) - : throwWarning(s, 'unknown document directive "' + i + '"'); + : throwWarning(s, 'unknown document directive "' + i + '"')); } - skipSeparationSpace(s, !0, -1), + (skipSeparationSpace(s, !0, -1), 0 === s.lineIndent && 45 === s.input.charCodeAt(s.position) && 45 === s.input.charCodeAt(s.position + 1) && @@ -34510,24 +34545,23 @@ ? 46 === s.input.charCodeAt(s.position) && ((s.position += 3), skipSeparationSpace(s, !0, -1)) : s.position < s.length - 1 && - throwError(s, 'end of the stream or a document separator is expected'); + throwError(s, 'end of the stream or a document separator is expected')); } function loadDocuments(s, o) { - (o = o || {}), + ((o = o || {}), 0 !== (s = String(s)).length && (10 !== s.charCodeAt(s.length - 1) && 13 !== s.charCodeAt(s.length - 1) && (s += '\n'), - 65279 === s.charCodeAt(0) && (s = s.slice(1))); + 65279 === s.charCodeAt(0) && (s = s.slice(1)))); var i = new State$1(s, o), u = s.indexOf('\0'); for ( -1 !== u && ((i.position = u), throwError(i, 'null byte is not allowed in input')), i.input += '\0'; 32 === i.input.charCodeAt(i.position); - ) - (i.lineIndent += 1), (i.position += 1); + ((i.lineIndent += 1), (i.position += 1)); for (; i.position < i.length - 1; ) readDocument(i); return i.documents; } @@ -34587,17 +34621,17 @@ Zr = /^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/; function encodeHex(s) { var o, i, u; - if (((o = s.toString(16).toUpperCase()), s <= 255)) (i = 'x'), (u = 2); - else if (s <= 65535) (i = 'u'), (u = 4); + if (((o = s.toString(16).toUpperCase()), s <= 255)) ((i = 'x'), (u = 2)); + else if (s <= 65535) ((i = 'u'), (u = 4)); else { if (!(s <= 4294967295)) throw new rr('code point within a string may not be greater than 0xFFFFFFFF'); - (i = 'U'), (u = 8); + ((i = 'U'), (u = 8)); } return '\\' + i + tr.repeat('0', u - o.length) + o; } function State(s) { - (this.schema = s.schema || Rr), + ((this.schema = s.schema || Rr), (this.indent = Math.max(1, s.indent || 2)), (this.noArrayIndent = s.noArrayIndent || !1), (this.skipInvalid = s.skipInvalid || !1), @@ -34606,13 +34640,13 @@ var i, u, _, w, x, C, j; if (null === o) return {}; for (i = {}, _ = 0, w = (u = Object.keys(o)).length; _ < w; _ += 1) - (x = u[_]), + ((x = u[_]), (C = String(o[x])), '!!' === x.slice(0, 2) && (x = 'tag:yaml.org,2002:' + x.slice(2)), (j = s.compiledTypeMap.fallback[x]) && Jr.call(j.styleAliases, C) && (C = j.styleAliases[C]), - (i[x] = C); + (i[x] = C)); return i; })(this.schema, s.styles || null)), (this.sortKeys = s.sortKeys || !1), @@ -34628,15 +34662,15 @@ (this.tag = null), (this.result = ''), (this.duplicates = []), - (this.usedDuplicates = null); + (this.usedDuplicates = null)); } function indentString(s, o) { for (var i, u = tr.repeat(' ', o), _ = 0, w = -1, x = '', C = s.length; _ < C; ) - -1 === (w = s.indexOf('\n', _)) + (-1 === (w = s.indexOf('\n', _)) ? ((i = s.slice(_)), (_ = C)) : ((i = s.slice(_, w + 1)), (_ = w + 1)), i.length && '\n' !== i && (x += u), - (x += i); + (x += i)); return x; } function generateNextLine(s, o) { @@ -34723,14 +34757,14 @@ if (o || x) for (j = 0; j < s.length; L >= 65536 ? (j += 2) : j++) { if (!isPrintable((L = codePointAt(s, j)))) return 5; - (Y = Y && isPlainSafe(L, B, C)), (B = L); + ((Y = Y && isPlainSafe(L, B, C)), (B = L)); } else { for (j = 0; j < s.length; L >= 65536 ? (j += 2) : j++) { if (10 === (L = codePointAt(s, j))) - ($ = !0), U && ((V = V || (j - z - 1 > u && ' ' !== s[z + 1])), (z = j)); + (($ = !0), U && ((V = V || (j - z - 1 > u && ' ' !== s[z + 1])), (z = j))); else if (!isPrintable(L)) return 5; - (Y = Y && isPlainSafe(L, B, C)), (B = L); + ((Y = Y && isPlainSafe(L, B, C)), (B = L)); } V = V || (U && j - z - 1 > u && ' ' !== s[z + 1]); } @@ -34803,9 +34837,9 @@ for (; (u = _.exec(s)); ) { var j = u[1], L = u[2]; - (i = ' ' === L[0]), + ((i = ' ' === L[0]), (w += j + (x || i || '' === L ? '' : '\n') + foldLine(L, o)), - (x = i); + (x = i)); } return w; })(o, x), @@ -34818,10 +34852,10 @@ '"' + (function escapeString(s) { for (var o, i = '', u = 0, _ = 0; _ < s.length; u >= 65536 ? (_ += 2) : _++) - (u = codePointAt(s, _)), + ((u = codePointAt(s, _)), !(o = Yr[u]) && isPrintable(u) ? ((i += s[_]), u >= 65536 && (i += s[_ + 1])) - : (i += o || encodeHex(u)); + : (i += o || encodeHex(u))); return i; })(o) + '"' @@ -34842,9 +34876,9 @@ function foldLine(s, o) { if ('' === s || ' ' === s[0]) return s; for (var i, u, _ = / [^ ]/g, w = 0, x = 0, C = 0, j = ''; (i = _.exec(s)); ) - (C = i.index) - w > o && + ((C = i.index) - w > o && ((u = x > w ? x : C), (j += '\n' + s.slice(w, u)), (w = u + 1)), - (x = C); + (x = C)); return ( (j += '\n'), s.length - w > o && x > w @@ -34860,14 +34894,14 @@ C = '', j = s.tag; for (_ = 0, w = i.length; _ < w; _ += 1) - (x = i[_]), + ((x = i[_]), s.replacer && (x = s.replacer.call(i, String(_), x)), (writeNode(s, o + 1, x, !0, !0, !1, !0) || (void 0 === x && writeNode(s, o + 1, null, !0, !0, !1, !0))) && ((u && '' === C) || (C += generateNextLine(s, o)), s.dump && 10 === s.dump.charCodeAt(0) ? (C += '-') : (C += '- '), - (C += s.dump)); - (s.tag = j), (s.dump = C || '[]'); + (C += s.dump))); + ((s.tag = j), (s.dump = C || '[]')); } function detectType(s, o, i) { var u, _, w, x, C, j; @@ -34902,7 +34936,7 @@ return !1; } function writeNode(s, o, i, u, _, w, x) { - (s.tag = null), (s.dump = i), detectType(s, i, !1) || detectType(s, i, !0); + ((s.tag = null), (s.dump = i), detectType(s, i, !1) || detectType(s, i, !0)); var C, j = Hr.call(s.dump), L = u; @@ -34936,7 +34970,7 @@ else if ('function' == typeof s.sortKeys) V.sort(s.sortKeys); else if (s.sortKeys) throw new rr('sortKeys must be a boolean or a function'); for (_ = 0, w = V.length; _ < w; _ += 1) - (L = ''), + ((L = ''), (u && '' === B) || (L += generateNextLine(s, o)), (C = i[(x = V[_])]), s.replacer && (C = s.replacer.call(i, x, C)), @@ -34949,8 +34983,8 @@ j && (L += generateNextLine(s, o)), writeNode(s, o + 1, C, !0, j) && (s.dump && 10 === s.dump.charCodeAt(0) ? (L += ':') : (L += ': '), - (B += L += s.dump))); - (s.tag = $), (s.dump = B || '{}'); + (B += L += s.dump)))); + ((s.tag = $), (s.dump = B || '{}')); })(s, o, s.dump, _), $ && (s.dump = '&ref_' + B + s.dump)) : (!(function writeFlowMapping(s, o, i) { @@ -34963,7 +34997,7 @@ L = s.tag, B = Object.keys(i); for (u = 0, _ = B.length; u < _; u += 1) - (C = ''), + ((C = ''), '' !== j && (C += ', '), s.condenseFlow && (C += '"'), (x = i[(w = B[u])]), @@ -34975,8 +35009,8 @@ (s.condenseFlow ? '"' : '') + ':' + (s.condenseFlow ? '' : ' ')), - writeNode(s, o, x, !1, !1) && (j += C += s.dump)); - (s.tag = L), (s.dump = '{' + j + '}'); + writeNode(s, o, x, !1, !1) && (j += C += s.dump))); + ((s.tag = L), (s.dump = '{' + j + '}')); })(s, o, s.dump), $ && (s.dump = '&ref_' + B + ' ' + s.dump)); else if ('[object Array]' === j) @@ -34992,12 +35026,12 @@ x = '', C = s.tag; for (u = 0, _ = i.length; u < _; u += 1) - (w = i[u]), + ((w = i[u]), s.replacer && (w = s.replacer.call(i, String(u), w)), (writeNode(s, o, w, !1, !1) || (void 0 === w && writeNode(s, o, null, !1, !1))) && - ('' !== x && (x += ',' + (s.condenseFlow ? '' : ' ')), (x += s.dump)); - (s.tag = C), (s.dump = '[' + x + ']'); + ('' !== x && (x += ',' + (s.condenseFlow ? '' : ' ')), (x += s.dump))); + ((s.tag = C), (s.dump = '[' + x + ']')); })(s, o, s.dump), $ && (s.dump = '&ref_' + B + ' ' + s.dump)); else { @@ -35133,7 +35167,7 @@ try { return mn.load(s); } catch (s) { - return o && o.errActions.newThrownErr(new Error(s)), {}; + return (o && o.errActions.newThrownErr(new Error(s)), {}); } })(_.text, i) ); @@ -35179,7 +35213,7 @@ actions: { scrollToElement: (s, o) => (i) => { try { - (o = o || i.fn.getScrollParent(s)), _n().createScroller(o).to(s); + ((o = o || i.fn.getScrollParent(s)), _n().createScroller(o).to(s)); } catch (s) { console.error(s); } @@ -35196,13 +35230,13 @@ ({ layoutActions: o, layoutSelectors: i, getConfigs: u }) => { if (u().deepLinking && s) { let u = s.slice(1); - '!' === u[0] && (u = u.slice(1)), '/' === u[0] && (u = u.slice(1)); + ('!' === u[0] && (u = u.slice(1)), '/' === u[0] && (u = u.slice(1))); const _ = u.split('/').map((s) => s || ''), w = i.isShownKeyFromUrlHashArray(_), [x, C = '', j = ''] = w; if ('operations' === x) { const s = i.isShownKeyFromUrlHashArray([C]); - C.indexOf('_') > -1 && + (C.indexOf('_') > -1 && (console.warn( 'Warning: escaping deep link whitespace with `_` will be unsupported in v4.0, use `%20` instead.' ), @@ -35210,9 +35244,9 @@ s.map((s) => s.replace(/_/g, ' ')), !0 )), - o.show(s, !0); + o.show(s, !0)); } - (C.indexOf('_') > -1 || j.indexOf('_') > -1) && + ((C.indexOf('_') > -1 || j.indexOf('_') > -1) && (console.warn( 'Warning: escaping deep link whitespace with `_` will be unsupported in v4.0, use `%20` instead.' ), @@ -35221,7 +35255,7 @@ !0 )), o.show(w, !0), - o.scrollTo(w); + o.scrollTo(w)); } } }, @@ -35276,7 +35310,7 @@ const { operation: i } = this.props, { tag: u, operationId: _ } = i.toObject(); let { isShownKey: w } = i.toObject(); - (w = w || ['operations', u, _]), o.layoutActions.readyToScroll(w, s); + ((w = w || ['operations', u, _]), o.layoutActions.readyToScroll(w, s)); }; render() { return Pe.createElement( @@ -35368,7 +35402,7 @@ try { return i.transform(s, o).filter((s) => !!s); } catch (o) { - return console.error('Transformer error:', o), s; + return (console.error('Transformer error:', o), s); } }, s @@ -35616,10 +35650,10 @@ return { type: Ln, payload: s }; } function actions_show(s, o = !0) { - return (s = normalizeArray(s)), { type: Fn, payload: { thing: s, shown: o } }; + return ((s = normalizeArray(s)), { type: Fn, payload: { thing: s, shown: o } }); } function changeMode(s, o = '') { - return (s = normalizeArray(s)), { type: Bn, payload: { thing: s, mode: o } }; + return ((s = normalizeArray(s)), { type: Bn, payload: { thing: s, mode: o } }); } const qn = { [Dn]: (s, o) => s.set('layout', o.payload), @@ -35638,7 +35672,8 @@ current = (s) => s.get('layout'), currentFilter = (s) => s.get('filter'), isShown = (s, o, i) => ( - (o = normalizeArray(o)), s.get('shown', (0, qe.fromJS)({})).get((0, qe.fromJS)(o), i) + (o = normalizeArray(o)), + s.get('shown', (0, qe.fromJS)({})).get((0, qe.fromJS)(o), i) ), whatMode = (s, o, i = '') => ((o = normalizeArray(o)), s.getIn(['modes', ...o], i)), $n = Ut( @@ -35653,7 +35688,7 @@ j = C(), { maxDisplayedTags: L } = j; let B = x.currentFilter(); - return B && !0 !== B && (_ = w.opsFilter(_, B)), L >= 0 && (_ = _.slice(0, L)), _; + return (B && !0 !== B && (_ = w.opsFilter(_, B)), L >= 0 && (_ = _.slice(0, L)), _); }; function plugins_layout() { return { @@ -35692,7 +35727,10 @@ (s, o) => (...i) => { const u = o.getConfigs().onComplete; - return Vn && 'function' == typeof u && (setTimeout(u, 0), (Vn = !1)), s(...i); + return ( + Vn && 'function' == typeof u && (setTimeout(u, 0), (Vn = !1)), + s(...i) + ); } } } @@ -35745,31 +35783,31 @@ x && x.size) ) for (let o of s.get('headers').entries()) { - addNewLine(), addIndent(); + (addNewLine(), addIndent()); let [s, i] = o; - addWordsWithoutLeadingSpace('-H', `${s}: ${i}`), - (_ = _ || (/^content-type$/i.test(s) && /^multipart\/form-data$/i.test(i))); + (addWordsWithoutLeadingSpace('-H', `${s}: ${i}`), + (_ = _ || (/^content-type$/i.test(s) && /^multipart\/form-data$/i.test(i)))); } const j = s.get('body'); if (j) if (_ && ['POST', 'PUT', 'PATCH'].includes(s.get('method'))) for (let [s, o] of j.entrySeq()) { let i = extractKey(s); - addNewLine(), + (addNewLine(), addIndent(), addWordsWithoutLeadingSpace('-F'), o instanceof at.File && 'string' == typeof o.valueOf() ? addWords(`${i}=${o.data}${o.type ? `;type=${o.type}` : ''}`) : o instanceof at.File ? addWords(`${i}=@${o.name}${o.type ? `;type=${o.type}` : ''}`) - : addWords(`${i}=${o}`); + : addWords(`${i}=${o}`)); } else if (j instanceof at.File) - addNewLine(), + (addNewLine(), addIndent(), - addWordsWithoutLeadingSpace(`--data-binary '@${j.name}'`); + addWordsWithoutLeadingSpace(`--data-binary '@${j.name}'`)); else { - addNewLine(), addIndent(), addWordsWithoutLeadingSpace('-d '); + (addNewLine(), addIndent(), addWordsWithoutLeadingSpace('-d ')); let o = j; qe.Map.isMap(o) ? addWordsWithoutLeadingSpace( @@ -36009,14 +36047,14 @@ this.props.expanded !== s.expanded && this.setState({ expanded: s.expanded }); } toggleCollapsed = () => { - this.props.onToggle && this.props.onToggle(this.props.modelName, !this.state.expanded), - this.setState({ expanded: !this.state.expanded }); + (this.props.onToggle && this.props.onToggle(this.props.modelName, !this.state.expanded), + this.setState({ expanded: !this.state.expanded })); }; onLoad = (s) => { if (s && this.props.layoutSelectors) { const o = this.props.layoutSelectors.getScrollToKey(); - $e().is(o, this.props.specPath) && this.toggleCollapsed(), - this.props.layoutActions.readyToScroll(this.props.specPath, s.parentElement); + ($e().is(o, this.props.specPath) && this.toggleCollapsed(), + this.props.layoutActions.readyToScroll(this.props.specPath, s.parentElement)); } }; render() { @@ -36225,10 +36263,10 @@ function _defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, u.key, u); + Object.defineProperty(s, u.key, u)); } } function _defineProperty(s, o, i) { @@ -36248,11 +36286,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -36271,7 +36309,7 @@ (_setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(s, o) { - return (s.__proto__ = o), s; + return ((s.__proto__ = o), s); }), _setPrototypeOf(s, o) ); @@ -36360,13 +36398,13 @@ (function _inherits(s, o) { if ('function' != typeof o && null !== o) throw new TypeError('Super expression must either be null or a function'); - (s.prototype = Object.create(o && o.prototype, { + ((s.prototype = Object.create(o && o.prototype, { constructor: { value: s, writable: !0, configurable: !0 } })), - o && _setPrototypeOf(s, o); + o && _setPrototypeOf(s, o)); })(ImmutablePureComponent, s), (function _createClass(s, o, i) { - return o && _defineProperties(s.prototype, o), i && _defineProperties(s, i), s; + return (o && _defineProperties(s.prototype, o), i && _defineProperties(s, i), s); })(ImmutablePureComponent, [ { key: 'shouldComponentUpdate', @@ -36560,8 +36598,8 @@ getCollapsedContent = () => ' '; handleToggle = (s, o) => { const { layoutActions: i } = this.props; - i.show([...this.getSchemaBasePath(), s], o), - o && this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(), s]); + (i.show([...this.getSchemaBasePath(), s], o), + o && this.props.specActions.requestResolvedSubtree([...this.getSchemaBasePath(), s])); }; onLoadModels = (s) => { s && this.props.layoutActions.readyToScroll(this.getSchemaBasePath(), s); @@ -37363,12 +37401,12 @@ class JsonSchema_array extends Pe.PureComponent { static defaultProps = os; constructor(s, o) { - super(s, o), (this.state = { value: valueOrEmptyList(s.value), schema: s.schema }); + (super(s, o), (this.state = { value: valueOrEmptyList(s.value), schema: s.schema })); } UNSAFE_componentWillReceiveProps(s) { const o = valueOrEmptyList(s.value); - o !== this.state.value && this.setState({ value: o }), - s.schema !== this.state.schema && this.setState({ schema: s.schema }); + (o !== this.state.value && this.setState({ value: o }), + s.schema !== this.state.schema && this.setState({ schema: s.schema })); } onChange = () => { this.props.onChange(this.state.value); @@ -37670,7 +37708,7 @@ const { Cache: i } = ut(); ut().Cache = Cache; const u = ut()(s, o); - return (ut().Cache = i), u; + return ((ut().Cache = i), u); }, ds = { string: (s) => @@ -37914,9 +37952,9 @@ (s && ce[o] && ce[o].xml && ce[o].xml.attribute ? (C[ce[o].xml.name || o] = _[o]) : pe(o, _[o]))); - return hs()(C) || le[Z].push({ _attr: C }), le; + return (hs()(C) || le[Z].push({ _attr: C }), le); } - return (le[Z] = hs()(C) ? _ : [{ _attr: C }, _]), le; + return ((le[Z] = hs()(C) ? _ : [{ _attr: C }, _]), le); } if ('object' === L) { for (let s in ce) @@ -37927,10 +37965,10 @@ pe(s)); if ((u && C && le[Z].push({ _attr: C }), hasExceededMaxProperties())) return le; if (!0 === V) - u + (u ? le[Z].push({ additionalProp: 'Anything can be here' }) : (le.additionalProp1 = {}), - de++; + de++); else if (V) { const i = objectify(V), _ = sampleFromSchemaGeneric(i, o, void 0, u); @@ -37944,7 +37982,7 @@ if (hasExceededMaxProperties()) return le; if (u) { const o = {}; - (o['additionalProp' + s] = _.notagname), le[Z].push(o); + ((o['additionalProp' + s] = _.notagname), le[Z].push(o)); } else le['additionalProp' + s] = _; de++; } @@ -38019,10 +38057,10 @@ x = w.getJsonSampleSchema(o, i, u, _); let C; try { - (C = mn.dump(mn.load(x), { lineWidth: -1 }, { schema: nn })), - '\n' === C[C.length - 1] && (C = C.slice(0, C.length - 1)); + ((C = mn.dump(mn.load(x), { lineWidth: -1 }, { schema: nn })), + '\n' === C[C.length - 1] && (C = C.slice(0, C.length - 1))); } catch (s) { - return console.error(s), 'error: could not generate yaml example'; + return (console.error(s), 'error: could not generate yaml example'); } return C.replace(/\t/g, ' '); }, @@ -38444,7 +38482,7 @@ let { specStr: _ } = i, w = null; try { - (s = s || _()), u.clear({ source: 'parser' }), (w = mn.load(s, { schema: nn })); + ((s = s || _()), u.clear({ source: 'parser' }), (w = mn.load(s, { schema: nn }))); } catch (s) { return ( console.error(s), @@ -38479,7 +38517,7 @@ requestInterceptor: $, responseInterceptor: V } = j(); - void 0 === s && (s = u.specJson()), void 0 === o && (o = u.url()); + (void 0 === s && (s = u.specJson()), void 0 === o && (o = u.url())); let U = C.getLineNumberForPath ? C.getLineNumberForPath : () => {}, z = u.specStr(); return x({ @@ -38515,7 +38553,7 @@ (s, { path: o, system: i }) => (s.has(i) || s.set(i, []), s.get(i).push(o), s), new Map() ); - (jo = []), + ((jo = []), s.forEach(async (s, o) => { if (!o) return void console.error( @@ -38615,7 +38653,7 @@ } catch (s) { console.error(s); } - }); + })); }, 35), requestResolvedSubtree = (s) => (o) => { jo.find(({ path: i, system: u }) => u === o && i.toString() === s.toString()) || @@ -38679,9 +38717,9 @@ s.server = w.selectedServer(o) || w.selectedServer(); const i = w.serverVariables({ server: s.server, namespace: o }).toJS(), u = w.serverVariables({ server: s.server }).toJS(); - (s.serverVariables = Object.keys(i).length ? i : u), + ((s.serverVariables = Object.keys(i).length ? i : u), (s.requestContentType = w.requestContentType(x, C)), - (s.responseContentType = w.responseContentType(x, C) || '*/*'); + (s.responseContentType = w.responseContentType(x, C) || '*/*')); const _ = w.requestBodyValue(x, C), j = w.requestBodyInclusionSetting(x, C); _ && _.toJS @@ -38693,25 +38731,25 @@ : (s.requestBody = _); } let V = Object.assign({}, s); - (V = o.buildRequest(V)), i.setRequest(s.pathName, s.method, V); - (s.requestInterceptor = async (o) => { + ((V = o.buildRequest(V)), i.setRequest(s.pathName, s.method, V)); + ((s.requestInterceptor = async (o) => { let u = await L.apply(void 0, [o]), _ = Object.assign({}, u); - return i.setMutatedRequest(s.pathName, s.method, _), u; + return (i.setMutatedRequest(s.pathName, s.method, _), u); }), - (s.responseInterceptor = B); + (s.responseInterceptor = B)); const U = Date.now(); return o .execute(s) .then((o) => { - (o.duration = Date.now() - U), i.setResponse(s.pathName, s.method, o); + ((o.duration = Date.now() - U), i.setResponse(s.pathName, s.method, o)); }) .catch((o) => { - 'Failed to fetch' === o.message && + ('Failed to fetch' === o.message && ((o.name = ''), (o.message = '**Failed to fetch.** \n**Possible Reasons:** \n - CORS \n - Network Failure \n - URL scheme must be "http" or "https" for CORS request.')), - i.setResponse(s.pathName, s.method, { error: !0, err: o }); + i.setResponse(s.pathName, s.method, { error: !0, err: o })); }); }, actions_execute = @@ -38801,7 +38839,7 @@ ), [yo]: (s, { payload: { res: o, path: i, method: u } }) => { let _; - (_ = o.error + ((_ = o.error ? Object.assign( { error: !0, @@ -38812,7 +38850,7 @@ o.err.response ) : o), - (_.headers = _.headers || {}); + (_.headers = _.headers || {})); let w = s.setIn(['responses', i, u], fromJSOrdered(_)); return ( at.Blob && @@ -38846,18 +38884,18 @@ wrap_actions_updateSpec = (s, { specActions: o }) => (...i) => { - s(...i), o.parseToJson(...i); + (s(...i), o.parseToJson(...i)); }, wrap_actions_updateJsonSpec = (s, { specActions: o }) => (...i) => { - s(...i), o.invalidateResolvedSubtreeCache(); + (s(...i), o.invalidateResolvedSubtreeCache()); const [u] = i, _ = jn()(u, ['paths']) || {}; - Object.keys(_).forEach((s) => { + (Object.keys(_).forEach((s) => { jn()(_, [s]).$ref && o.requestResolvedSubtree(['paths', s]); }), - o.requestResolvedSubtree(['components', 'securitySchemes']); + o.requestResolvedSubtree(['components', 'securitySchemes'])); }, wrap_actions_executeRequest = (s, { specActions: o }) => @@ -38895,9 +38933,9 @@ function __() { this.constructor = s; } - extendStatics(s, o), + (extendStatics(s, o), (s.prototype = - null === o ? Object.create(o) : ((__.prototype = o.prototype), new __())); + null === o ? Object.create(o) : ((__.prototype = o.prototype), new __()))); }; })(), To = Object.prototype.hasOwnProperty; @@ -38980,21 +39018,21 @@ C ); } - return Mo(PatchError, s), PatchError; + return (Mo(PatchError, s), PatchError); })(Error), Ro = No, Do = _deepClone, Lo = { add: function (s, o, i) { - return (s[o] = this.value), { newDocument: i }; + return ((s[o] = this.value), { newDocument: i }); }, remove: function (s, o, i) { var u = s[o]; - return delete s[o], { newDocument: i, removed: u }; + return (delete s[o], { newDocument: i, removed: u }); }, replace: function (s, o, i) { var u = s[o]; - return (s[o] = this.value), { newDocument: i, removed: u }; + return ((s[o] = this.value), { newDocument: i, removed: u }); }, move: function (s, o, i) { var u = getValueByPointer(i, this.path); @@ -39016,7 +39054,7 @@ return { newDocument: i, test: _areEquals(s[o], this.value) }; }, _get: function (s, o, i) { - return (this.value = s[o]), { newDocument: i }; + return ((this.value = s[o]), { newDocument: i }); } }, Bo = { @@ -39031,7 +39069,7 @@ }, replace: function (s, o, i) { var u = s[o]; - return (s[o] = this.value), { newDocument: i, removed: u }; + return ((s[o] = this.value), { newDocument: i, removed: u }); }, move: Lo.move, copy: Lo.copy, @@ -39041,7 +39079,7 @@ function getValueByPointer(s, o) { if ('' == o) return s; var i = { op: '_get', path: o }; - return applyOperation(s, i), i.value; + return (applyOperation(s, i), i.value); } function applyOperation(s, o, i, u, _, w) { if ( @@ -39053,8 +39091,8 @@ '' === o.path) ) { var x = { newDocument: s }; - if ('add' === o.op) return (x.newDocument = o.value), x; - if ('replace' === o.op) return (x.newDocument = o.value), (x.removed = s), x; + if ('add' === o.op) return ((x.newDocument = o.value), x); + if ('replace' === o.op) return ((x.newDocument = o.value), (x.removed = s), x); if ('move' === o.op || 'copy' === o.op) return ( (x.newDocument = getValueByPointer(s, o.from)), @@ -39064,10 +39102,10 @@ if ('test' === o.op) { if (((x.test = _areEquals(s, o.value)), !1 === x.test)) throw new Ro('Test operation failed', 'TEST_OPERATION_FAILED', w, o, s); - return (x.newDocument = s), x; + return ((x.newDocument = s), x); } - if ('remove' === o.op) return (x.removed = s), (x.newDocument = null), x; - if ('_get' === o.op) return (o.value = s), x; + if ('remove' === o.op) return ((x.removed = s), (x.newDocument = null), x); + if ('_get' === o.op) return ((o.value = s), x); if (i) throw new Ro( 'Operation `op` property is not one of operations defined in RFC-6902', @@ -39147,8 +39185,8 @@ throw new Ro('Patch sequence must be an array', 'SEQUENCE_NOT_AN_ARRAY'); u || (s = _deepClone(s)); for (var w = new Array(o.length), x = 0, C = o.length; x < C; x++) - (w[x] = applyOperation(s, o[x], i, !0, _, x)), (s = w[x].newDocument); - return (w.newDocument = s), w; + ((w[x] = applyOperation(s, o[x], i, !0, _, x)), (s = w[x].newDocument)); + return ((w.newDocument = s), w); } function applyReducer(s, o, i) { var u = applyOperation(s, o); @@ -39278,10 +39316,10 @@ } var Fo = new WeakMap(), qo = function qo(s) { - (this.observers = new Map()), (this.obj = s); + ((this.observers = new Map()), (this.obj = s)); }, $o = function $o(s, o) { - (this.callback = s), (this.observer = o); + ((this.callback = s), (this.observer = o)); }; function unobserve(s, o) { o.unobserve(); @@ -39296,15 +39334,15 @@ return s.observers.get(o); })(u, o); i = _ && _.observer; - } else (u = new qo(s)), Fo.set(s, u); + } else ((u = new qo(s)), Fo.set(s, u)); if (i) return i; if (((i = {}), (u.value = _deepClone(s)), o)) { - (i.callback = o), (i.next = null); + ((i.callback = o), (i.next = null)); var dirtyCheck = function () { generate(i); }, fastCheck = function () { - clearTimeout(i.next), (i.next = setTimeout(dirtyCheck)); + (clearTimeout(i.next), (i.next = setTimeout(dirtyCheck))); }; 'undefined' != typeof window && (window.addEventListener('mouseup', fastCheck), @@ -39317,7 +39355,7 @@ (i.patches = []), (i.object = s), (i.unobserve = function () { - generate(i), + (generate(i), clearTimeout(i.next), (function removeObserverFromMirror(s, o) { s.observers.delete(o.callback); @@ -39327,7 +39365,7 @@ window.removeEventListener('keyup', fastCheck), window.removeEventListener('mousedown', fastCheck), window.removeEventListener('keydown', fastCheck), - window.removeEventListener('change', fastCheck)); + window.removeEventListener('change', fastCheck))); }), u.observers.set(o, new $o(o, i)), i @@ -39336,10 +39374,10 @@ function generate(s, o) { void 0 === o && (o = !1); var i = Fo.get(s.object); - _generate(i.value, s.object, s.patches, '', o), - s.patches.length && applyPatch(i.value, s.patches); + (_generate(i.value, s.object, s.patches, '', o), + s.patches.length && applyPatch(i.value, s.patches)); var u = s.patches; - return u.length > 0 && ((s.patches = []), s.callback && s.callback(u)), u; + return (u.length > 0 && ((s.patches = []), s.callback && s.callback(u)), u); } function _generate(s, o, i, u, _) { if (o !== s) { @@ -39404,7 +39442,7 @@ function compare(s, o, i) { void 0 === i && (i = !1); var u = []; - return _generate(s, o, u, '', i), u; + return (_generate(s, o, u, '', i), u); } Object.assign({}, ie, ae, { JsonPatchError: No, @@ -39440,7 +39478,7 @@ 'merge' === (o = { ...o, path: o.path && normalizeJSONPath(o.path) }).op) ) { const i = getInByJsonPath(s, o.path); - Object.assign(i, o.value), applyPatch(s, [replace(o.path, i)]); + (Object.assign(i, o.value), applyPatch(s, [replace(o.path, i)])); } else if ('mergeDeep' === o.op) { const i = getInByJsonPath(s, o.path), u = Uo()(i, o.value); @@ -39450,19 +39488,20 @@ s, Object.keys(o.value).reduce( (s, i) => ( - s.push({ op: 'add', path: `/${normalizeJSONPath(i)}`, value: o.value[i] }), s + s.push({ op: 'add', path: `/${normalizeJSONPath(i)}`, value: o.value[i] }), + s ), [] ) ); } else if ('replace' === o.op && '' === o.path) { let { value: u } = o; - i.allowMetaPatches && + (i.allowMetaPatches && o.meta && isAdditiveMutation(o) && (Array.isArray(o.value) || lib_isObject(o.value)) && (u = { ...u, ...o.meta }), - (s = u); + (s = u)); } else if ( (applyPatch(s, [o]), i.allowMetaPatches && @@ -39556,7 +39595,7 @@ const _ = Object.keys(s).map((u) => forEach(s[u], o, i.concat(u))); _ && (u = u.concat(_)); } - return (u = flatten(u)), u; + return ((u = flatten(u)), u); } function lib_normalizeArray(s) { return Array.isArray(s) ? s : [s]; @@ -39596,7 +39635,7 @@ try { return getValueByPointer(s, o); } catch (s) { - return console.error(s), {}; + return (console.error(s), {}); } } var Wo = __webpack_require__(48675); @@ -39612,10 +39651,10 @@ null != i && 'object' == typeof i && Object.hasOwn(i, 'cause') && !('cause' in this)) ) { const { cause: s } = i; - (this.cause = s), + ((this.cause = s), s instanceof Error && 'stack' in s && - (this.stack = `${this.stack}\nCAUSE: ${s.stack}`); + (this.stack = `${this.stack}\nCAUSE: ${s.stack}`)); } } }; @@ -39636,10 +39675,10 @@ null != o && 'object' == typeof o && Object.hasOwn(o, 'cause') && !('cause' in this)) ) { const { cause: s } = o; - (this.cause = s), + ((this.cause = s), s instanceof Error && 'stack' in s && - (this.stack = `${this.stack}\nCAUSE: ${s.stack}`); + (this.stack = `${this.stack}\nCAUSE: ${s.stack}`)); } } } @@ -39772,11 +39811,11 @@ s.flags ? s.flags : (s.global ? 'g' : '') + - (s.ignoreCase ? 'i' : '') + - (s.multiline ? 'm' : '') + - (s.sticky ? 'y' : '') + - (s.unicode ? 'u' : '') + - (s.dotAll ? 's' : '') + (s.ignoreCase ? 'i' : '') + + (s.multiline ? 'm' : '') + + (s.sticky ? 'y' : '') + + (s.unicode ? 'u' : '') + + (s.dotAll ? 's' : '') ); } function _arrayFromIterator(s) { @@ -39840,7 +39879,7 @@ for (o in s) !_has(o, s) || (_ && 'length' === o) || (u[u.length] = o); if (Ei) for (i = Oi.length - 1; i >= 0; ) - _has((o = Oi[i]), s) && !Mi(u, o) && (u[u.length] = o), (i -= 1); + (_has((o = Oi[i]), s) && !Mi(u, o) && (u[u.length] = o), (i -= 1)); return u; }) : _curry1(function keys(s) { @@ -40014,7 +40053,7 @@ ); } function _map(s, o) { - for (var i = 0, u = o.length, _ = Array(u); i < u; ) (_[i] = s(o[i])), (i += 1); + for (var i = 0, u = o.length, _ = Array(u); i < u; ) ((_[i] = s(o[i])), (i += 1)); return _; } function _quote(s) { @@ -40065,7 +40104,7 @@ }; } function _arrayReduce(s, o, i) { - for (var u = 0, _ = i.length; u < _; ) (o = s(o, i[u])), (u += 1); + for (var u = 0, _ = i.length; u < _; ) ((o = s(o, i[u])), (u += 1)); return o; } const aa = @@ -40106,7 +40145,7 @@ }; var la = (function () { function XFilter(s, o) { - (this.xf = o), (this.f = s); + ((this.xf = o), (this.f = s)); } return ( (XFilter.prototype['@@transducer/init'] = _xfBase_init), @@ -40127,14 +40166,14 @@ return _isObject(o) ? _arrayReduce( function (i, u) { - return s(o[u]) && (i[u] = o[u]), i; + return (s(o[u]) && (i[u] = o[u]), i); }, {}, Wi(o) ) : (function _filter(s, o) { for (var i = 0, u = o.length, _ = []; i < u; ) - s(o[i]) && (_[_.length] = o[i]), (i += 1); + (s(o[i]) && (_[_.length] = o[i]), (i += 1)); return _; })(s, o); }) @@ -40386,12 +40425,12 @@ return function () { for (var u = [], _ = 0, w = s, x = 0, C = !1; x < o.length || _ < arguments.length; ) { var j; - x < o.length && (!_isPlaceholder(o[x]) || _ >= arguments.length) + (x < o.length && (!_isPlaceholder(o[x]) || _ >= arguments.length) ? (j = o[x]) : ((j = arguments[_]), (_ += 1)), (u[x] = j), _isPlaceholder(j) ? (C = !0) : (w -= 1), - (x += 1); + (x += 1)); } return !C && w <= 0 ? i.apply(this, u) : _arity(Math.max(0, w), _curryN(s, u, i)); }; @@ -40428,12 +40467,12 @@ } var sl = (function () { function XDropLastWhile(s, o) { - (this.f = s), (this.retained = []), (this.xf = o); + ((this.f = s), (this.retained = []), (this.xf = o)); } return ( (XDropLastWhile.prototype['@@transducer/init'] = _xfBase_init), (XDropLastWhile.prototype['@@transducer/result'] = function (s) { - return (this.retained = null), this.xf['@@transducer/result'](s); + return ((this.retained = null), this.xf['@@transducer/result'](s)); }), (XDropLastWhile.prototype['@@transducer/step'] = function (s, o) { return this.f(o) ? this.retain(s, o) : this.flush(s, o); @@ -40446,7 +40485,7 @@ ); }), (XDropLastWhile.prototype.retain = function (s, o) { - return this.retained.push(o), s; + return (this.retained.push(o), s); }), XDropLastWhile ); @@ -40461,7 +40500,7 @@ var vl = _curry1(function flip(s) { return za(s.length, function (o, i) { var u = Array.prototype.slice.call(arguments, 0); - return (u[0] = i), (u[1] = o), s.apply(this, u); + return ((u[0] = i), (u[1] = o), s.apply(this, u)); }); }); const _l = vl(_curry2(_includes)); @@ -40469,7 +40508,7 @@ return pipe(tl(''), ul(_l(s)), yl(''))(o); }); function _iterableReduce(s, o, i) { - for (var u = i.next(); !u.done; ) (o = s(o, u.value)), (u = i.next()); + for (var u = i.next(); !u.done; ) ((o = s(o, u.value)), (u = i.next())); return o; } function _methodReduce(s, o, i, u) { @@ -40478,7 +40517,7 @@ const wl = _createReduce(_arrayReduce, _methodReduce, _iterableReduce); var Sl = (function () { function XMap(s, o) { - (this.xf = o), (this.f = s); + ((this.xf = o), (this.f = s)); } return ( (XMap.prototype['@@transducer/init'] = _xfBase_init), @@ -40506,7 +40545,7 @@ case '[object Object]': return _arrayReduce( function (i, u) { - return (i[u] = s(o[u])), i; + return ((i[u] = s(o[u])), i); }, {}, Wi(o) @@ -40535,8 +40574,8 @@ var u = (s = s || []).length, _ = o.length, w = []; - for (i = 0; i < u; ) (w[w.length] = s[i]), (i += 1); - for (i = 0; i < _; ) (w[w.length] = o[i]), (i += 1); + for (i = 0; i < u; ) ((w[w.length] = s[i]), (i += 1)); + for (i = 0; i < _; ) ((w[w.length] = o[i]), (i += 1)); return w; })(s, kl(i, o)); }, @@ -40625,7 +40664,7 @@ throw TypeError('`'.concat(o, '` must be a string')); }; const Ql = function replaceAll(s, o, i) { - !(function checkArguments(s, o, i) { + (!(function checkArguments(s, o, i) { if (null == i || null == s || null == o) throw TypeError('Input values must not be `null` or `undefined`'); })(s, o, i), @@ -40634,7 +40673,7 @@ (function checkSearchValue(s) { if (!('string' == typeof s || s instanceof String || s instanceof RegExp)) throw TypeError('`searchValue` must be a string or an regexp'); - })(s); + })(s)); var u = new RegExp(Jl(s) ? s : Xl(s), 'g'); return Hl(u, o, i); }; @@ -40687,7 +40726,7 @@ stripHash = (s) => { const o = s.indexOf('#'); let i = s; - return o >= 0 && (i = s.substring(0, o)), i; + return (o >= 0 && (i = s.substring(0, o)), i); }, url_cwd = () => { if (Go.browser) return stripHash(globalThis.location.href); @@ -40708,7 +40747,7 @@ return ((s) => { const o = [/\?/g, '%3F', /#/g, '%23']; let i = s; - isWindows() && (i = i.replace(/\\/g, '/')), (i = encodeURI(i)); + (isWindows() && (i = i.replace(/\\/g, '/')), (i = encodeURI(i))); for (let s = 0; s < o.length; s += 2) i = i.replace(o[s], o[s + 1]); return i; })(toFileSystemPath(s)); @@ -40736,10 +40775,10 @@ function legacy_defineProperties(s, o) { for (var i = 0; i < o.length; i++) { var u = o[i]; - (u.enumerable = u.enumerable || !1), + ((u.enumerable = u.enumerable || !1), (u.configurable = !0), 'value' in u && (u.writable = !0), - Object.defineProperty(s, u.key, u); + Object.defineProperty(s, u.key, u)); } } function _instanceof(s, o) { @@ -40770,7 +40809,7 @@ x = !0 ); } catch (s) { - (C = !0), (_ = s); + ((C = !0), (_ = s)); } finally { try { x || null == i.return || i.return(); @@ -40802,13 +40841,13 @@ ? 'symbol' : typeof s; } - void 0 === globalThis.fetch && (globalThis.fetch = ic), + (void 0 === globalThis.fetch && (globalThis.fetch = ic), void 0 === globalThis.Headers && (globalThis.Headers = lc), void 0 === globalThis.Request && (globalThis.Request = cc), void 0 === globalThis.Response && (globalThis.Response = ac), void 0 === globalThis.FormData && (globalThis.FormData = pc), void 0 === globalThis.File && (globalThis.File = hc), - void 0 === globalThis.Blob && (globalThis.Blob = dc); + void 0 === globalThis.Blob && (globalThis.Blob = dc)); var __typeError = function (s) { throw TypeError(s); }, @@ -40816,7 +40855,7 @@ return o.has(s) || __typeError('Cannot ' + i); }, __privateGet = function (s, o, i) { - return __accessCheck(s, o, 'read from private field'), i ? i.call(s) : o.get(s); + return (__accessCheck(s, o, 'read from private field'), i ? i.call(s) : o.get(s)); }, __privateAdd = function (s, o, i) { return o.has(s) @@ -40826,7 +40865,11 @@ : o.set(s, i); }, __privateSet = function (s, o, i, u) { - return __accessCheck(s, o, 'write to private field'), u ? u.call(s, i) : o.set(s, i), i; + return ( + __accessCheck(s, o, 'write to private field'), + u ? u.call(s, i) : o.set(s, i), + i + ); }, to_string = function (s) { return Object.prototype.toString.call(s); @@ -40891,7 +40934,7 @@ i[L] = s[L]; } } catch (s) { - (w = !0), (x = s); + ((w = !0), (x = s)); } finally { try { _ || null == j.return || j.return(); @@ -40934,14 +40977,14 @@ isLast: !1, update: function update(s) { var o = arguments.length > 1 && void 0 !== arguments[1] && arguments[1]; - $.isRoot || ($.parent.node[$.key] = s), ($.node = s), o && (B = !1); + ($.isRoot || ($.parent.node[$.key] = s), ($.node = s), o && (B = !1)); }, delete: function _delete(s) { - delete $.parent.node[$.key], s && (B = !1); + (delete $.parent.node[$.key], s && (B = !1)); }, remove: function remove(s) { - fc($.parent.node) ? $.parent.node.splice($.key, 1) : delete $.parent.node[$.key], - s && (B = !1); + (fc($.parent.node) ? $.parent.node.splice($.key, 1) : delete $.parent.node[$.key], + s && (B = !1)); }, keys: null, before: function before(s) { @@ -40966,15 +41009,15 @@ if (!w) return $; function update_state() { if ('object' === _type_of($.node) && null !== $.node) { - ($.keys && $.node_ === $.node) || ($.keys = x($.node)), - ($.isLeaf = 0 === $.keys.length); + (($.keys && $.node_ === $.node) || ($.keys = x($.node)), + ($.isLeaf = 0 === $.keys.length)); for (var o = 0; o < _.length; o++) if (_[o].node_ === s) { $.circular = _[o]; break; } - } else ($.isLeaf = !0), ($.keys = null); - ($.notLeaf = !$.isLeaf), ($.notRoot = !$.isRoot); + } else (($.isLeaf = !0), ($.keys = null)); + (($.notLeaf = !$.isLeaf), ($.notRoot = !$.isRoot)); } update_state(); var V = o.call($, $.node); @@ -40982,7 +41025,7 @@ return $; if ('object' === _type_of($.node) && null !== $.node && !$.circular) { var U; - _.push($), update_state(); + (_.push($), update_state()); var z = !0, Y = !1, Z = void 0; @@ -40999,18 +41042,18 @@ le = _sliced_to_array(ee.value, 2), ce = le[0], pe = le[1]; - u.push(pe), L.pre && L.pre.call($, $.node[pe], pe); + (u.push(pe), L.pre && L.pre.call($, $.node[pe], pe)); var de = walker($.node[pe]); - C && Ec.call($.node, pe) && !is_writable($.node, pe) && ($.node[pe] = de.node), + (C && Ec.call($.node, pe) && !is_writable($.node, pe) && ($.node[pe] = de.node), (de.isLast = !!(null === (ae = $.keys) || void 0 === ae ? void 0 : ae.length) && +ce == $.keys.length - 1), (de.isFirst = 0 == +ce), L.post && L.post.call($, de), - u.pop(); + u.pop()); } } catch (s) { - (Y = !0), (Z = s); + ((Y = !0), (Z = s)); } finally { try { z || null == ie.return || ie.return(); @@ -41020,24 +41063,26 @@ } _.pop(); } - return L.after && L.after.call($, $.node), $; + return (L.after && L.after.call($, $.node), $); })(s).node; } var Ic = (function () { function Traverse(s) { var o = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : jc; - !(function _class_call_check(s, o) { + (!(function _class_call_check(s, o) { if (!(s instanceof o)) throw new TypeError('Cannot call a class as a function'); })(this, Traverse), __privateAdd(this, kc), __privateAdd(this, Oc), __privateSet(this, kc, s), - __privateSet(this, Oc, o); + __privateSet(this, Oc, o)); } return ( (function _create_class(s, o, i) { return ( - o && legacy_defineProperties(s.prototype, o), i && legacy_defineProperties(s, i), s + o && legacy_defineProperties(s.prototype, o), + i && legacy_defineProperties(s, i), + s ); })(Traverse, [ { @@ -41079,9 +41124,9 @@ u = 0; for (u = 0; u < s.length - 1; u++) { var _ = s[u]; - Ec.call(i, _) || (i[_] = {}), (i = i[_]); + (Ec.call(i, _) || (i[_] = {}), (i = i[_])); } - return (i[s[u]] = o), o; + return ((i[s[u]] = o), o); } }, { @@ -41151,7 +41196,7 @@ for (var _ = 0; _ < s.length; _++) if (s[_] === u) return o[_]; if ('object' === (void 0 === u ? 'undefined' : _type_of(u)) && null !== u) { var w = legacy_copy(u, i); - s.push(u), o.push(w); + (s.push(u), o.push(w)); var x = i.includeSymbols ? own_enumerable_keys : Object.keys, C = !0, j = !1, @@ -41166,7 +41211,7 @@ w[V] = clone(u[V]); } } catch (s) { - (j = !0), (L = s); + ((j = !0), (L = s)); } finally { try { C || null == $.return || $.return(); @@ -41174,7 +41219,7 @@ if (j) throw L; } } - return s.pop(), o.pop(), w; + return (s.pop(), o.pop(), w); } return u; })(__privateGet(this, kc)); @@ -41184,11 +41229,11 @@ Traverse ); })(); - (kc = new WeakMap()), (Oc = new WeakMap()); + ((kc = new WeakMap()), (Oc = new WeakMap())); var traverse = function (s, o) { return new Ic(s, o); }; - (traverse.get = function (s, o, i) { + ((traverse.get = function (s, o, i) { return new Ic(s, i).get(o); }), (traverse.set = function (s, o, i, u) { @@ -41214,7 +41259,7 @@ }), (traverse.clone = function (s, o) { return new Ic(s, o).clone(); - }); + })); var Pc = traverse; const Mc = 'application/json, application/yaml', Nc = 'https://swagger.io', @@ -41381,7 +41426,8 @@ !(function patchValueAlreadyInPath(s, o) { const i = [s]; return ( - o.path.reduce((s, o) => (i.push(s[o]), s[o]), s), pointToAncestor(o.value) + o.path.reduce((s, o) => (i.push(s[o]), s[o]), s), + pointToAncestor(o.value) ); function pointToAncestor(s) { return ( @@ -41506,7 +41552,7 @@ if (isFreelyNamed(w)) return; if (!Array.isArray(s)) { const s = new TypeError('allOf must be an array'); - return (s.fullPath = i), s; + return ((s.fullPath = i), s); } let x = !1, C = _.value; @@ -41527,7 +41573,7 @@ if (x) return null; x = !0; const s = new TypeError('Elements in allOf must be objects'); - return (s.fullPath = i), j.push(s); + return ((s.fullPath = i), j.push(s)); } j.push(u.mergeDeep(w, s)); const _ = (function generateAbsoluteRefPatches( @@ -41577,7 +41623,7 @@ o[_].default = u.parameterMacro(w, x); } catch (s) { const o = new Error(s); - return (o.fullPath = i), o; + return ((o.fullPath = i), o); } } return zo.replace(i, o); @@ -41594,7 +41640,7 @@ _[o].default = u.modelPropertyMacro(_[o]); } catch (s) { const o = new Error(s); - return (o.fullPath = i), o; + return ((o.fullPath = i), o); } return zo.replace(i, _); } @@ -41626,7 +41672,7 @@ : s.slice(0, -1).reduce((s, i) => { if (!s) return s; const { children: u } = s; - return !u[i] && o && (u[i] = context_tree_createNode(null, s)), u[i]; + return (!u[i] && o && (u[i] = context_tree_createNode(null, s)), u[i]); }, this.root); } } @@ -41653,7 +41699,7 @@ return s.filter(o); } constructor(s) { - Object.assign( + (Object.assign( this, { spec: '', @@ -41683,7 +41729,7 @@ .filter(zo.isFunction)), this.patches.push(zo.add([], this.spec)), this.patches.push(zo.context([], this.context)), - this.updatePatches(this.patches); + this.updatePatches(this.patches)); } debug(s, ...o) { this.debugLevel === s && console.log(...o); @@ -41764,7 +41810,7 @@ } updatePluginHistory(s, o) { const i = this.constructor.getPluginName(s); - (this.pluginHistory[i] = this.pluginHistory[i] || []), this.pluginHistory[i].push(o); + ((this.pluginHistory[i] = this.pluginHistory[i] || []), this.pluginHistory[i].push(o)); } updatePatches(s) { zo.normalizeArray(s).forEach((s) => { @@ -41774,11 +41820,11 @@ if (!zo.isObject(s)) return void this.debug('updatePatches', 'Got a non-object patch', s); if ((this.showDebug && this.allPatches.push(s), zo.isPromise(s.value))) - return this.promisedPatches.push(s), void this.promisedPatchThen(s); + return (this.promisedPatches.push(s), void this.promisedPatchThen(s)); if (zo.isContextPatch(s)) return void this.setContext(s.path, s.value); zo.isMutation(s) && this.updateMutations(s); } catch (s) { - console.error(s), this.errors.push(s); + (console.error(s), this.errors.push(s)); } }); } @@ -41801,10 +41847,10 @@ (s.value = s.value .then((o) => { const i = { ...s, value: o }; - this.removePromisedPatch(s), this.updatePatches(i); + (this.removePromisedPatch(s), this.updatePatches(i)); }) .catch((o) => { - this.removePromisedPatch(s), this.updatePatches(o); + (this.removePromisedPatch(s), this.updatePatches(o)); })), s.value ); @@ -41848,7 +41894,7 @@ const s = this.nextPromisedPatch(); if (s) return s.then(() => this.dispatch()).catch(() => this.dispatch()); const o = { spec: this.state, errors: this.errors }; - return this.showDebug && (o.patches = this.allPatches), Promise.resolve(o); + return (this.showDebug && (o.patches = this.allPatches), Promise.resolve(o)); } if ( ((s.pluginCount = s.pluginCount || new WeakMap()), @@ -41875,7 +41921,7 @@ updatePatches(o(i, s.getLib())); } } catch (s) { - console.error(s), updatePatches([Object.assign(Object.create(s), { plugin: o })]); + (console.error(s), updatePatches([Object.assign(Object.create(s), { plugin: o })])); } finally { s.updatePluginHistory(o, { mutationIndex: u }); } @@ -41916,7 +41962,7 @@ } class FileWithData extends File { constructor(s, o = '', i = {}) { - super([s], o, i), (this.data = s); + (super([s], o, i), (this.data = s)); } valueOf() { return this.data; @@ -42143,7 +42189,7 @@ return s; }, new FormData()); })(s.form); - (s.formdata = o), (s.body = o); + ((s.formdata = o), (s.body = o)); } else s.body = encodeFormOrQuery(u); delete s.form; } @@ -42152,13 +42198,13 @@ let w = ''; if (_) { const s = new URLSearchParams(_); - Object.keys(i).forEach((o) => s.delete(o)), (w = String(s)); + (Object.keys(i).forEach((o) => s.delete(o)), (w = String(s))); } const x = ((...s) => { const o = s.filter((s) => s).join('&'); return o ? `?${o}` : ''; })(w, encodeFormOrQuery(i)); - (s.url = u + x), delete s.query; + ((s.url = u + x), delete s.query); } return s; } @@ -42193,7 +42239,7 @@ ? JSON.parse(s) : mn.load(s); })(s, _); - (u.body = o), (u.obj = o); + ((u.body = o), (u.obj = o)); } catch (s) { u.parseError = s; } @@ -42201,22 +42247,22 @@ }); } async function http_http(s, o = {}) { - 'object' == typeof s && (s = (o = s).url), + ('object' == typeof s && (s = (o = s).url), (o.headers = o.headers || {}), (o = serializeRequest(o)).headers && Object.keys(o.headers).forEach((s) => { const i = o.headers[s]; 'string' == typeof i && (o.headers[s] = i.replace(/\n+/g, ' ')); }), - o.requestInterceptor && (o = (await o.requestInterceptor(o)) || o); + o.requestInterceptor && (o = (await o.requestInterceptor(o)) || o)); const i = o.headers['content-type'] || o.headers['Content-Type']; let u; /multipart\/form-data/i.test(i) && (delete o.headers['content-type'], delete o.headers['Content-Type']); try { - (u = await (o.userFetch || fetch)(o.url, o)), + ((u = await (o.userFetch || fetch)(o.url, o)), (u = await serializeResponse(u, s, o)), - o.responseInterceptor && (u = (await o.responseInterceptor(u)) || u); + o.responseInterceptor && (u = (await o.responseInterceptor(u)) || u)); } catch (s) { if (!u) throw s; const o = new Error(u.statusText || `response status is ${u.status}`); @@ -42321,13 +42367,13 @@ const s = u[C]; if (s.length > 1) s.forEach((s, o) => { - (s.__originalOperationId = s.__originalOperationId || s.operationId), - (s.operationId = `${C}${o + 1}`); + ((s.__originalOperationId = s.__originalOperationId || s.operationId), + (s.operationId = `${C}${o + 1}`)); }); else if (void 0 !== x.operationId) { const o = s[0]; - (o.__originalOperationId = o.__originalOperationId || x.operationId), - (o.operationId = C); + ((o.__originalOperationId = o.__originalOperationId || x.operationId), + (o.operationId = C)); } } if ('parameters' !== i) { @@ -42354,7 +42400,7 @@ } } } - return (o.$$normalized = !0), s; + return ((o.$$normalized = !0), s); } const cu = { name: 'generic', @@ -42468,7 +42514,7 @@ } var Ou = (function () { function XAll(s, o) { - (this.xf = o), (this.f = s), (this.all = !0); + ((this.xf = o), (this.f = s), (this.all = !0)); } return ( (XAll.prototype['@@transducer/init'] = _xfBase_init), @@ -42504,7 +42550,7 @@ const ju = Au; class Annotation extends Cu.Om { constructor(s, o, i) { - super(s, o, i), (this.element = 'annotation'); + (super(s, o, i), (this.element = 'annotation')); } get code() { return this.attributes.get('code'); @@ -42516,13 +42562,13 @@ const Iu = Annotation; class Comment extends Cu.Om { constructor(s, o, i) { - super(s, o, i), (this.element = 'comment'); + (super(s, o, i), (this.element = 'comment')); } } const Pu = Comment; class ParseResult extends Cu.wE { constructor(s, o, i) { - super(s, o, i), (this.element = 'parseResult'); + (super(s, o, i), (this.element = 'parseResult')); } get api() { return this.children.filter((s) => s.classes.contains('api')).first; @@ -42559,7 +42605,7 @@ const Mu = ParseResult; class SourceMap extends Cu.wE { constructor(s, o, i) { - super(s, o, i), (this.element = 'sourceMap'); + (super(s, o, i), (this.element = 'sourceMap')); } get positionStart() { return this.children.filter((s) => s.classes.contains('position')).get(0); @@ -42571,7 +42617,7 @@ if (void 0 === s) return; const o = new Cu.wE([s.start.row, s.start.column, s.start.char]), i = new Cu.wE([s.end.row, s.end.column, s.end.char]); - o.classes.push('position'), i.classes.push('position'), this.push(o).push(i); + (o.classes.push('position'), i.classes.push('position'), this.push(o).push(i)); } } const Tu = SourceMap, @@ -42738,7 +42784,7 @@ const ee = { ...z, replaceWith(s, o) { - z.replaceWith(s, o), (Y = s); + (z.replaceWith(s, o), (Y = s)); } }; for (let L = 0; L < s.length; L += 1) @@ -42757,7 +42803,7 @@ if (o === _) return o; if (void 0 !== o) { if (!x) return o; - (Y = o), (Z = !0); + ((Y = o), (Z = !0)); } } } @@ -42769,7 +42815,7 @@ const z = { ...V, replaceWith(s, o) { - V.replaceWith(s, o), (U = s); + (V.replaceWith(s, o), (U = s)); } }; for (let _ = 0; _ < s.length; _ += 1) @@ -42809,7 +42855,7 @@ const ee = { ...z, replaceWith(s, o) { - z.replaceWith(s, o), (Y = s); + (z.replaceWith(s, o), (Y = s)); } }; for (let L = 0; L < s.length; L += 1) @@ -42823,7 +42869,7 @@ if (o === _) return o; if (void 0 !== o) { if (!x) return o; - (Y = o), (Z = !0); + ((Y = o), (Z = !0)); } } } @@ -42835,7 +42881,7 @@ const z = { ...V, replaceWith(s, o) { - V.replaceWith(s, o), (U = s); + (V.replaceWith(s, o), (U = s)); } }; for (let _ = 0; _ < s.length; _ += 1) @@ -42894,7 +42940,7 @@ ae = B(ae); for (const [s, o] of ie) ae[s] = o; } - (ee = U.index), (Z = U.keys), (ie = U.edits), (Y = U.inArray), (U = U.prev); + ((ee = U.index), (Z = U.keys), (ie = U.edits), (Y = U.inArray), (U = U.prev)); } else if (z !== w && void 0 !== z) { if (((i = Y ? ee : Z[ee]), (ae = z[i]), ae === w || void 0 === ae)) continue; le.push(i); @@ -42912,8 +42958,8 @@ for (const [s, i] of Object.entries(u)) o[s] = i; const _ = { replaceWith(o, u) { - 'function' == typeof u ? u(o, ae, i, z, le, ce) : z && (z[i] = o), - s || (ae = o); + ('function' == typeof u ? u(o, ae, i, z, le, ce) : z && (z[i] = o), + s || (ae = o)); } }; ye = w.call(o, ae, i, z, le, ce, _); @@ -42939,13 +42985,13 @@ } var de; if ((void 0 === ye && fe && ie.push([i, ae]), !s)) - (U = { inArray: Y, index: ee, keys: Z, edits: ie, prev: U }), + ((U = { inArray: Y, index: ee, keys: Z, edits: ie, prev: U }), (Y = Array.isArray(ae)), (Z = Y ? ae : null !== (de = V[j(ae)]) && void 0 !== de ? de : []), (ee = -1), (ie = []), z !== w && void 0 !== z && ce.push(z), - (z = ae); + (z = ae)); } while (void 0 !== U); return 0 !== ie.length ? ie[ie.length - 1][1] : s; }; @@ -42993,7 +43039,7 @@ ae = B(ae); for (const [s, o] of ie) ae[s] = o; } - (ee = U.index), (Z = U.keys), (ie = U.edits), (Y = U.inArray), (U = U.prev); + ((ee = U.index), (Z = U.keys), (ie = U.edits), (Y = U.inArray), (U = U.prev)); } else if (z !== w && void 0 !== z) { if (((i = Y ? ee : Z[ee]), (ae = z[i]), ae === w || void 0 === ae)) continue; le.push(i); @@ -43010,8 +43056,8 @@ for (const [s, i] of Object.entries(u)) o[s] = i; const _ = { replaceWith(o, u) { - 'function' == typeof u ? u(o, ae, i, z, le, ce) : z && (z[i] = o), - s || (ae = o); + ('function' == typeof u ? u(o, ae, i, z, le, ce) : z && (z[i] = o), + s || (ae = o)); } }; fe = await w.call(o, ae, i, z, le, ce, _); @@ -43032,20 +43078,20 @@ } var pe; if ((void 0 === fe && de && ie.push([i, ae]), !s)) - (U = { inArray: Y, index: ee, keys: Z, edits: ie, prev: U }), + ((U = { inArray: Y, index: ee, keys: Z, edits: ie, prev: U }), (Y = Array.isArray(ae)), (Z = Y ? ae : null !== (pe = V[j(ae)]) && void 0 !== pe ? pe : []), (ee = -1), (ie = []), z !== w && void 0 !== z && ce.push(z), - (z = ae); + (z = ae)); } while (void 0 !== U); return 0 !== ie.length ? ie[ie.length - 1][1] : s; }; const Gu = class CloneError extends Jo { value; constructor(s, o) { - super(s, o), void 0 !== o && (this.value = o.value); + (super(s, o), void 0 !== o && (this.value = o.value)); } }; const Yu = class DeepCloneError extends Gu {}; @@ -43059,19 +43105,19 @@ w = Nu(o) ? cloneDeep(o, u) : o, x = Nu(_) ? cloneDeep(_, u) : _, C = new Cu.KeyValuePair(w, x); - return i.set(s, C), C; + return (i.set(s, C), C); } if (s instanceof Cu.ot) { const mapper = (s) => cloneDeep(s, u), o = [...s].map(mapper), _ = new Cu.ot(o); - return i.set(s, _), _; + return (i.set(s, _), _); } if (s instanceof Cu.G6) { const mapper = (s) => cloneDeep(s, u), o = [...s].map(mapper), _ = new Cu.G6(o); - return i.set(s, _), _; + return (i.set(s, _), _); } if (Nu(s)) { const o = cloneShallow(s); @@ -43183,10 +43229,10 @@ returnOnTrue; returnOnFalse; constructor({ predicate: s = es_F, returnOnTrue: o, returnOnFalse: i } = {}) { - (this.result = []), + ((this.result = []), (this.predicate = s), (this.returnOnTrue = o), - (this.returnOnFalse = i); + (this.returnOnFalse = i)); } enter(s) { return this.predicate(s) @@ -43245,13 +43291,13 @@ content = []; reference = void 0; constructor(s) { - (this.content = s), (this.reference = []); + ((this.content = s), (this.reference = [])); } toReference() { return this.reference; } toArray() { - return this.reference.push(...this.content), this.reference; + return (this.reference.push(...this.content), this.reference); } }; const rp = class EphemeralObject { @@ -43259,7 +43305,7 @@ content = []; reference = void 0; constructor(s) { - (this.content = s), (this.reference = {}); + ((this.content = s), (this.reference = {})); } toReference() { return this.reference; @@ -43273,7 +43319,7 @@ enter: (s) => { if (this.references.has(s)) return this.references.get(s).toReference(); const o = new rp(s.content); - return this.references.set(s, o), o; + return (this.references.set(s, o), o); } }; EphemeralObject = { leave: (s) => s.toObject() }; @@ -43282,7 +43328,7 @@ enter: (s) => { if (this.references.has(s)) return this.references.get(s).toReference(); const o = new tp(s.content); - return this.references.set(s, o), o; + return (this.references.set(s, o), o); } }; EphemeralArray = { leave: (s) => s.toArray() }; @@ -43409,17 +43455,17 @@ const _p = bp; class Namespace extends Cu.g$ { constructor() { - super(), + (super(), this.register('annotation', Iu), this.register('comment', Pu), this.register('parseResult', Mu), - this.register('sourceMap', Tu); + this.register('sourceMap', Tu)); } } const Ep = new Namespace(), createNamespace = (s) => { const o = new Namespace(); - return ku(s) && o.use(s), o; + return (ku(s) && o.use(s), o); }, wp = Ep, toolbox = () => ({ predicates: { ...le }, namespace: wp }), @@ -43436,7 +43482,7 @@ j = mergeAll(C.map(La({}, 'visitor')), { ...w }); C.forEach(_p(['pre'], [])); const L = visitor_visit(s, j, w); - return C.forEach(_p(['post'], [])), L; + return (C.forEach(_p(['post'], [])), L); }; dispatchPluginsSync[Symbol.for('nodejs.util.promisify.custom')] = async (s, o, i = {}) => { if (0 === o.length) return s; @@ -43449,7 +43495,7 @@ B = j(C.map(La({}, 'visitor')), { ...w }); await Promise.allSettled(C.map(_p(['pre'], []))); const $ = await L(s, B, w); - return await Promise.allSettled(C.map(_p(['post'], []))), $; + return (await Promise.allSettled(C.map(_p(['post'], []))), $); }; const refract = (s, { Type: o, plugins: i = [] }) => { const u = new o(s); @@ -43467,7 +43513,7 @@ (s) => (o, i = {}) => refract(o, { ...i, Type: s }); - (Cu.Sh.refract = createRefractor(Cu.Sh)), + ((Cu.Sh.refract = createRefractor(Cu.Sh)), (Cu.wE.refract = createRefractor(Cu.wE)), (Cu.Om.refract = createRefractor(Cu.Om)), (Cu.bd.refract = createRefractor(Cu.bd)), @@ -43478,12 +43524,12 @@ (Iu.refract = createRefractor(Iu)), (Pu.refract = createRefractor(Pu)), (Mu.refract = createRefractor(Mu)), - (Tu.refract = createRefractor(Tu)); + (Tu.refract = createRefractor(Tu))); const computeEdges = (s, o = new WeakMap()) => ( $u(s) ? (o.set(s.key, s), computeEdges(s.key, o), o.set(s.value, s), computeEdges(s.value, o)) : s.children.forEach((i) => { - o.set(i, s), computeEdges(i, o); + (o.set(i, s), computeEdges(i, o)); }), o ); @@ -43533,7 +43579,7 @@ const Op = class CompilationJsonPointerError extends Cp { tokens; constructor(s, o) { - super(s, o), void 0 !== o && (this.tokens = [...o.tokens]); + (super(s, o), void 0 !== o && (this.tokens = [...o.tokens])); } }, es_compile = (s) => { @@ -43573,7 +43619,7 @@ const Rp = Wl(Number.isInteger) ? za(1, Ea(Number.isInteger, Number)) : Np; var Dp = (function () { function XTake(s, o) { - (this.xf = o), (this.n = s), (this.i = 0); + ((this.xf = o), (this.n = s), (this.i = 0)); } return ( (XTake.prototype['@@transducer/init'] = _xfBase_init), @@ -43603,7 +43649,7 @@ const qp = ra(''); var $p = (function () { function XDropWhile(s, o) { - (this.xf = o), (this.f = s); + ((this.xf = o), (this.f = s)); } return ( (XDropWhile.prototype['@@transducer/init'] = _xfBase_init), @@ -43642,7 +43688,7 @@ const Wp = class InvalidJsonPointerError extends Cp { pointer; constructor(s, o) { - super(s, o), void 0 !== o && (this.pointer = o.pointer); + (super(s, o), void 0 !== o && (this.pointer = o.pointer)); } }, uriToPointer = (s) => { @@ -43675,13 +43721,13 @@ failedTokenPosition; element; constructor(s, o) { - super(s, o), + (super(s, o), void 0 !== o && ((this.pointer = o.pointer), Array.isArray(o.tokens) && (this.tokens = [...o.tokens]), (this.failedToken = o.failedToken), (this.failedTokenPosition = o.failedTokenPosition), - (this.element = o.element)); + (this.element = o.element))); } }, es_evaluate = (s, o) => { @@ -43738,13 +43784,13 @@ }; class Callback extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'callback'); + (super(s, o, i), (this.element = 'callback')); } } const Hp = Callback; class Components extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'components'); + (super(s, o, i), (this.element = 'components')); } get schemas() { return this.get('schemas'); @@ -43804,7 +43850,7 @@ const Jp = Components; class Contact extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'contact'); + (super(s, o, i), (this.element = 'contact')); } get name() { return this.get('name'); @@ -43828,7 +43874,7 @@ const Gp = Contact; class Discriminator extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'discriminator'); + (super(s, o, i), (this.element = 'discriminator')); } get propertyName() { return this.get('propertyName'); @@ -43846,7 +43892,7 @@ const Yp = Discriminator; class Encoding extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'encoding'); + (super(s, o, i), (this.element = 'encoding')); } get contentType() { return this.get('contentType'); @@ -43882,7 +43928,7 @@ const Xp = Encoding; class Example extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'example'); + (super(s, o, i), (this.element = 'example')); } get summary() { return this.get('summary'); @@ -43912,7 +43958,7 @@ const Zp = Example; class ExternalDocumentation extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'externalDocumentation'); + (super(s, o, i), (this.element = 'externalDocumentation')); } get description() { return this.get('description'); @@ -43930,7 +43976,7 @@ const Qp = ExternalDocumentation; class Header extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'header'); + (super(s, o, i), (this.element = 'header')); } get required() { return this.hasKey('required') ? this.get('required') : new Cu.bd(!1); @@ -44005,7 +44051,7 @@ const th = Header; class Info extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'info'), this.classes.push('info'); + (super(s, o, i), (this.element = 'info'), this.classes.push('info')); } get title() { return this.get('title'); @@ -44047,7 +44093,7 @@ const rh = Info; class License extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'license'); + (super(s, o, i), (this.element = 'license')); } get name() { return this.get('name'); @@ -44065,7 +44111,7 @@ const uh = License; class Link extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'link'); + (super(s, o, i), (this.element = 'link')); } get operationRef() { return this.get('operationRef'); @@ -44122,7 +44168,7 @@ const dh = Link; class MediaType extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'mediaType'); + (super(s, o, i), (this.element = 'mediaType')); } get schema() { return this.get('schema'); @@ -44152,7 +44198,7 @@ const fh = MediaType; class OAuthFlow extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'oAuthFlow'); + (super(s, o, i), (this.element = 'oAuthFlow')); } get authorizationUrl() { return this.get('authorizationUrl'); @@ -44182,7 +44228,7 @@ const vh = OAuthFlow; class OAuthFlows extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'oAuthFlows'); + (super(s, o, i), (this.element = 'oAuthFlows')); } get implicit() { return this.get('implicit'); @@ -44212,16 +44258,16 @@ const _h = OAuthFlows; class Openapi extends Cu.Om { constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), (this.element = 'openapi'), this.classes.push('spec-version'), - this.classes.push('version'); + this.classes.push('version')); } } const wh = Openapi; class OpenApi3_0 extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'openApi3_0'), this.classes.push('api'); + (super(s, o, i), (this.element = 'openApi3_0'), this.classes.push('api')); } get openapi() { return this.get('openapi'); @@ -44275,7 +44321,7 @@ const Oh = OpenApi3_0; class Operation extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'operation'); + (super(s, o, i), (this.element = 'operation')); } get tags() { return this.get('tags'); @@ -44353,7 +44399,7 @@ const jh = Operation; class Parameter extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'parameter'); + (super(s, o, i), (this.element = 'parameter')); } get name() { return this.get('name'); @@ -44440,7 +44486,7 @@ const Ih = Parameter; class PathItem extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'pathItem'); + (super(s, o, i), (this.element = 'pathItem')); } get $ref() { return this.get('$ref'); @@ -44524,13 +44570,13 @@ const Ph = PathItem; class Paths extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'paths'); + (super(s, o, i), (this.element = 'paths')); } } const Rh = Paths; class Reference extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'reference'), this.classes.push('openapi-reference'); + (super(s, o, i), (this.element = 'reference'), this.classes.push('openapi-reference')); } get $ref() { return this.get('$ref'); @@ -44542,7 +44588,7 @@ const Dh = Reference; class RequestBody extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'requestBody'); + (super(s, o, i), (this.element = 'requestBody')); } get description() { return this.get('description'); @@ -44566,7 +44612,7 @@ const Lh = RequestBody; class Response_Response extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'response'); + (super(s, o, i), (this.element = 'response')); } get description() { return this.get('description'); @@ -44596,7 +44642,7 @@ const Fh = Response_Response; class Responses extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'responses'); + (super(s, o, i), (this.element = 'responses')); } get default() { return this.get('default'); @@ -44609,7 +44655,7 @@ const Hh = class UnsupportedOperationError extends Ho {}; class JSONSchema extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'JSONSchemaDraft4'); + (super(s, o, i), (this.element = 'JSONSchemaDraft4')); } get idProp() { return this.get('id'); @@ -44837,7 +44883,7 @@ const Jh = JSONSchema; class JSONReference extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'JSONReference'), this.classes.push('json-reference'); + (super(s, o, i), (this.element = 'JSONReference'), this.classes.push('json-reference')); } get $ref() { return this.get('$ref'); @@ -44849,7 +44895,7 @@ const Gh = JSONReference; class Media extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'media'); + (super(s, o, i), (this.element = 'media')); } get binaryEncoding() { return this.get('binaryEncoding'); @@ -44867,7 +44913,7 @@ const Qh = Media; class LinkDescription extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'linkDescription'); + (super(s, o, i), (this.element = 'linkDescription')); } get href() { return this.get('href'); @@ -44922,7 +44968,7 @@ var sd = _curry2(function mapObjIndexed(s, o) { return _arrayReduce( function (i, u) { - return (i[u] = s(o[u], u, o)), i; + return ((i[u] = s(o[u], u, o)), i); }, {}, Wi(o) @@ -44936,7 +44982,7 @@ if (0 === s.length || ld(o)) return !1; for (var i = o, u = 0; u < s.length; ) { if (ld(i) || !_has(s[u], i)) return !1; - (i = i[s[u]]), (u += 1); + ((i = i[s[u]]), (u += 1)); } return !0; }); @@ -44983,21 +45029,21 @@ Fu(s) && s.forEach((s, o, _) => { const w = cloneShallow(_); - (w.value = cloneUnlessOtherwiseSpecified(s, i)), u.content.push(w); + ((w.value = cloneUnlessOtherwiseSpecified(s, i)), u.content.push(w)); }), o.forEach((o, _, w) => { const x = serializers_value(_); let C; if (Fu(s) && s.hasKey(x) && i.isMergeableElement(o)) { const u = s.get(x); - (C = cloneShallow(w)), + ((C = cloneShallow(w)), (C.value = ((s, o) => { if ('function' != typeof o.customMerge) return deepmerge; const i = o.customMerge(s, o); return 'function' == typeof i ? i : deepmerge; - })(_, i)(u, o)); - } else (C = cloneShallow(w)), (C.value = cloneUnlessOtherwiseSpecified(o, i)); - u.remove(x), u.content.push(C); + })(_, i)(u, o))); + } else ((C = cloneShallow(w)), (C.value = cloneUnlessOtherwiseSpecified(o, i))); + (u.remove(x), u.content.push(C)); }), u ); @@ -45009,12 +45055,12 @@ function deepmerge(s, o, i) { var u, _, w; const x = { ...vd, ...i }; - (x.isMergeableElement = + ((x.isMergeableElement = null !== (u = x.isMergeableElement) && void 0 !== u ? u : vd.isMergeableElement), (x.arrayElementMerge = null !== (_ = x.arrayElementMerge) && void 0 !== _ ? _ : vd.arrayElementMerge), (x.objectElementMerge = - null !== (w = x.objectElementMerge) && void 0 !== w ? w : vd.objectElementMerge); + null !== (w = x.objectElementMerge) && void 0 !== w ? w : vd.objectElementMerge)); const C = qu(o); if (!(C === qu(s))) return cloneUnlessOtherwiseSpecified(o, x); const j = @@ -45040,16 +45086,16 @@ Object.assign(this, s); } copyMetaAndAttributes(s, o) { - (s.meta.length > 0 || o.meta.length > 0) && + ((s.meta.length > 0 || o.meta.length > 0) && ((o.meta = deepmerge(o.meta, s.meta)), hasElementSourceMap(s) && o.meta.set('sourceMap', s.meta.get('sourceMap'))), (s.attributes.length > 0 || s.meta.length > 0) && - (o.attributes = deepmerge(o.attributes, s.attributes)); + (o.attributes = deepmerge(o.attributes, s.attributes))); } }; const Ed = class FallbackVisitor extends _d { enter(s) { - return (this.element = cloneDeep(s)), Ju; + return ((this.element = cloneDeep(s)), Ju); } }, copyProps = (s, o, i = []) => { @@ -45088,7 +45134,7 @@ -1 === x.indexOf(u) && (copyProps(w, u, ['constructor', ...i]), x.push(u)); } } - return (w.constructor = o), w; + return ((w.constructor = o), w); }, unique = (s) => s.filter((o, i) => s.indexOf(o) == i), getIngredientWithProp = (s, o) => { @@ -45124,7 +45170,7 @@ const _ = getIngredientWithProp(i, s); if (void 0 === _) throw new Error('Cannot set new properties on Proxies created by ts-mixer'); - return (_[i] = u), !0; + return ((_[i] = u), !0); }, deleteProperty() { throw new Error('Cannot delete properties on Proxies created by ts-mixer'); @@ -45196,7 +45242,7 @@ ...(null !== (o = getMixinsForClass(s)) && void 0 !== o ? o : []) ].filter((s) => !i.has(s)); for (let s of w) u.add(s); - i.add(s), u.delete(s); + (i.add(s), u.delete(s)); } return [...i]; })(...s) @@ -45210,7 +45256,7 @@ }, getDecoratorsForClass = (s) => { let o = Od.get(s); - return o || ((o = {}), Od.set(s, o)), o; + return (o || ((o = {}), Od.set(s, o)), o); }; function Mixin(...s) { var o, i, u; @@ -45229,7 +45275,7 @@ null !== w && 'function' == typeof this[w] && this[w].apply(this, o); } var x, C; - (MixedClass.prototype = + ((MixedClass.prototype = 'copy' === xd ? hardMixProtos(_, MixedClass) : ((x = _), (C = MixedClass), proxyMix([...x, { constructor: C }]))), @@ -45238,7 +45284,7 @@ 'copy' === Sd ? hardMixProtos(s, null, ['prototype']) : proxyMix(s, Function.prototype) - ); + )); let j = MixedClass; if ('none' !== kd) { const _ = @@ -45256,17 +45302,17 @@ const o = s(j); o && (j = o); } - applyPropAndMethodDecorators( + (applyPropAndMethodDecorators( null !== (i = null == _ ? void 0 : _.static) && void 0 !== i ? i : {}, j ), applyPropAndMethodDecorators( null !== (u = null == _ ? void 0 : _.instance) && void 0 !== u ? u : {}, j.prototype - ); + )); } var L, B; - return (L = j), (B = s), Cd.set(L, B), j; + return ((L = j), (B = s), Cd.set(L, B), j); } const applyPropAndMethodDecorators = (s, o) => { const i = s.property, @@ -45276,14 +45322,14 @@ for (let s in u) for (let i of u[s]) i(o, s, Object.getOwnPropertyDescriptor(o, s)); }; const Ad = _curry2(function pick(s, o) { - for (var i = {}, u = 0; u < s.length; ) s[u] in o && (i[s[u]] = o[s[u]]), (u += 1); + for (var i = {}, u = 0; u < s.length; ) (s[u] in o && (i[s[u]] = o[s[u]]), (u += 1)); return i; }); const Id = class SpecificationVisitor extends _d { specObj; passingOptionsNames = ['specObj']; constructor({ specObj: s, ...o }) { - super({ ...o }), (this.specObj = s); + (super({ ...o }), (this.specObj = s)); } retrievePassingOptions() { return Ad(this.passingOptionsNames, this); @@ -45312,7 +45358,7 @@ specPath; ignoredFields; constructor({ specPath: s, ignoredFields: o, ...i }) { - super({ ...i }), (this.specPath = s), (this.ignoredFields = o || []); + (super({ ...i }), (this.specPath = s), (this.ignoredFields = o || [])); } ObjectElement(s) { const o = this.specPath(s), @@ -45326,9 +45372,9 @@ ) { const i = this.toRefractedElement([...o, 'fixedFields', serializers_value(u)], s), w = new Cu.Pr(cloneDeep(u), i); - this.copyMetaAndAttributes(_, w), + (this.copyMetaAndAttributes(_, w), w.classes.push('fixed-field'), - this.element.content.push(w); + this.element.content.push(w)); } else this.ignoredFields.includes(serializers_value(u)) || this.element.content.push(cloneDeep(_)); @@ -45340,9 +45386,9 @@ }; class JSONSchemaVisitor extends Mixin(Md, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Jh()), - (this.specPath = Tl(['document', 'objects', 'JSONSchema'])); + (this.specPath = Tl(['document', 'objects', 'JSONSchema']))); } } const Td = JSONSchemaVisitor; @@ -45358,7 +45404,7 @@ const o = isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] : ['document', 'objects', 'JSONSchema']; - return (this.element = this.toRefractedElement(o, s)), Ju; + return ((this.element = this.toRefractedElement(o, s)), Ju); } ArrayElement(s) { return ( @@ -45380,7 +45426,7 @@ const Dd = class RequiredVisitor extends Ed { ArrayElement(s) { const o = this.enter(s); - return this.element.classes.push('json-schema-required'), o; + return (this.element.classes.push('json-schema-required'), o); } }; const Ld = _curry1(function allPass(s) { @@ -45419,10 +45465,10 @@ ignoredFields; fieldPatternPredicate = es_F; constructor({ specPath: s, ignoredFields: o, fieldPatternPredicate: i, ...u }) { - super({ ...u }), + (super({ ...u }), (this.specPath = s), (this.ignoredFields = o || []), - 'function' == typeof i && (this.fieldPatternPredicate = i); + 'function' == typeof i && (this.fieldPatternPredicate = i)); } ObjectElement(s) { return ( @@ -45434,9 +45480,9 @@ const u = this.specPath(s), _ = this.toRefractedElement(u, s), w = new Cu.Pr(cloneDeep(o), _); - this.copyMetaAndAttributes(i, w), + (this.copyMetaAndAttributes(i, w), w.classes.push('patterned-field'), - this.element.content.push(w); + this.element.content.push(w)); } else this.ignoredFields.includes(serializers_value(o)) || this.element.content.push(cloneDeep(i)); @@ -45448,64 +45494,66 @@ }; const Wd = class MapVisitor extends Ud { constructor(s) { - super(s), (this.fieldPatternPredicate = Vd); + (super(s), (this.fieldPatternPredicate = Vd)); } }; class PropertiesVisitor extends Mixin(Wd, Nd, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-properties'), (this.specPath = (s) => isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] - : ['document', 'objects', 'JSONSchema']); + : ['document', 'objects', 'JSONSchema'])); } } const Kd = PropertiesVisitor; class PatternPropertiesVisitor extends Mixin(Wd, Nd, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-patternProperties'), (this.specPath = (s) => isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] - : ['document', 'objects', 'JSONSchema']); + : ['document', 'objects', 'JSONSchema'])); } } const Hd = PatternPropertiesVisitor; class DependenciesVisitor extends Mixin(Wd, Nd, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-dependencies'), (this.specPath = (s) => isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] - : ['document', 'objects', 'JSONSchema']); + : ['document', 'objects', 'JSONSchema'])); } } const Jd = DependenciesVisitor; const Gd = class EnumVisitor extends Ed { ArrayElement(s) { const o = this.enter(s); - return this.element.classes.push('json-schema-enum'), o; + return (this.element.classes.push('json-schema-enum'), o); } }; const Yd = class TypeVisitor extends Ed { StringElement(s) { const o = this.enter(s); - return this.element.classes.push('json-schema-type'), o; + return (this.element.classes.push('json-schema-type'), o); } ArrayElement(s) { const o = this.enter(s); - return this.element.classes.push('json-schema-type'), o; + return (this.element.classes.push('json-schema-type'), o); } }; class AllOfVisitor extends Mixin(Id, Nd, Ed) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-allOf'); + (super(s), + (this.element = new Cu.wE()), + this.element.classes.push('json-schema-allOf')); } ArrayElement(s) { return ( @@ -45524,7 +45572,9 @@ const Xd = AllOfVisitor; class AnyOfVisitor extends Mixin(Id, Nd, Ed) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-anyOf'); + (super(s), + (this.element = new Cu.wE()), + this.element.classes.push('json-schema-anyOf')); } ArrayElement(s) { return ( @@ -45543,7 +45593,9 @@ const Zd = AnyOfVisitor; class OneOfVisitor extends Mixin(Id, Nd, Ed) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-oneOf'); + (super(s), + (this.element = new Cu.wE()), + this.element.classes.push('json-schema-oneOf')); } ArrayElement(s) { return ( @@ -45562,19 +45614,21 @@ const Qd = OneOfVisitor; class DefinitionsVisitor extends Mixin(Wd, Nd, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-definitions'), (this.specPath = (s) => isJSONReferenceLikeElement(s) ? ['document', 'objects', 'JSONReference'] - : ['document', 'objects', 'JSONSchema']); + : ['document', 'objects', 'JSONSchema'])); } } const ef = DefinitionsVisitor; class LinksVisitor extends Mixin(Id, Nd, Ed) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-links'); + (super(s), + (this.element = new Cu.wE()), + this.element.classes.push('json-schema-links')); } ArrayElement(s) { return ( @@ -45590,20 +45644,20 @@ const rf = LinksVisitor; class JSONReferenceVisitor extends Mixin(Md, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Gh()), - (this.specPath = Tl(['document', 'objects', 'JSONReference'])); + (this.specPath = Tl(['document', 'objects', 'JSONReference']))); } ObjectElement(s) { const o = Md.prototype.ObjectElement.call(this, s); - return Ru(this.element.$ref) && this.element.classes.push('reference-element'), o; + return (Ru(this.element.$ref) && this.element.classes.push('reference-element'), o); } } const of = JSONReferenceVisitor; const af = class $RefVisitor extends Ed { StringElement(s) { const o = this.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; const lf = _curry3(function ifElse(s, o, i) { @@ -45693,39 +45747,39 @@ const kf = class AlternatingVisitor extends Id { alternator; constructor({ alternator: s, ...o }) { - super({ ...o }), (this.alternator = s); + (super({ ...o }), (this.alternator = s)); } enter(s) { const o = this.alternator.map(({ predicate: s, specPath: o }) => lf(s, Tl(o), Nl)), i = xf(o)(s); - return (this.element = this.toRefractedElement(i, s)), Ju; + return ((this.element = this.toRefractedElement(i, s)), Ju); } }; const Cf = class SchemaOrReferenceVisitor extends kf { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isJSONReferenceLikeElement, specPath: ['document', 'objects', 'JSONReference'] }, { predicate: es_T, specPath: ['document', 'objects', 'JSONSchema'] } - ]); + ])); } }; class MediaVisitor extends Mixin(Md, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new Qh()), - (this.specPath = Tl(['document', 'objects', 'Media'])); + (this.specPath = Tl(['document', 'objects', 'Media']))); } } const Of = MediaVisitor; class LinkDescriptionVisitor extends Mixin(Md, Ed) { constructor(s) { - super(s), + (super(s), (this.element = new td()), - (this.specPath = Tl(['document', 'objects', 'LinkDescription'])); + (this.specPath = Tl(['document', 'objects', 'LinkDescription']))); } } const jf = { @@ -45871,7 +45925,7 @@ (s) => (o, i = {}) => refractor_refract(o, { specPath: s, ...i }); - (Jh.refract = refractor_createRefractor([ + ((Jh.refract = refractor_createRefractor([ 'visitors', 'document', 'objects', @@ -45898,10 +45952,10 @@ 'objects', 'LinkDescription', '$visitor' - ])); + ]))); const Wf = class Schema_Schema extends Jh { constructor(s, o, i) { - super(s, o, i), (this.element = 'schema'), this.classes.push('json-schema-draft-4'); + (super(s, o, i), (this.element = 'schema'), this.classes.push('json-schema-draft-4')); } get idProp() { throw new Hh('idProp getter in Schema class is not not supported.'); @@ -46026,13 +46080,13 @@ }; class SecurityRequirement extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'securityRequirement'); + (super(s, o, i), (this.element = 'securityRequirement')); } } const Hf = SecurityRequirement; class SecurityScheme extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'securityScheme'); + (super(s, o, i), (this.element = 'securityScheme')); } get type() { return this.get('type'); @@ -46086,7 +46140,7 @@ const Jf = SecurityScheme; class Server extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'server'); + (super(s, o, i), (this.element = 'server')); } get url() { return this.get('url'); @@ -46110,7 +46164,7 @@ const Gf = Server; class ServerVariable extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'serverVariable'); + (super(s, o, i), (this.element = 'serverVariable')); } get enum() { return this.get('enum'); @@ -46134,7 +46188,7 @@ const Xf = ServerVariable; class Tag extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'tag'); + (super(s, o, i), (this.element = 'tag')); } get name() { return this.get('name'); @@ -46158,7 +46212,7 @@ const Qf = Tag; class Xml extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'xml'); + (super(s, o, i), (this.element = 'xml')); } get name() { return this.get('name'); @@ -46198,16 +46252,16 @@ Object.assign(this, s); } copyMetaAndAttributes(s, o) { - (s.meta.length > 0 || o.meta.length > 0) && + ((s.meta.length > 0 || o.meta.length > 0) && ((o.meta = deepmerge(o.meta, s.meta)), hasElementSourceMap(s) && o.meta.set('sourceMap', s.meta.get('sourceMap'))), (s.attributes.length > 0 || s.meta.length > 0) && - (o.attributes = deepmerge(o.attributes, s.attributes)); + (o.attributes = deepmerge(o.attributes, s.attributes))); } }; const rm = class FallbackVisitor_FallbackVisitor extends tm { enter(s) { - return (this.element = cloneDeep(s)), Ju; + return ((this.element = cloneDeep(s)), Ju); } }; const nm = class SpecificationVisitor_SpecificationVisitor extends tm { @@ -46222,11 +46276,11 @@ openApiSemanticElement: u, ..._ }) { - super({ ..._ }), + (super({ ..._ }), (this.specObj = s), (this.openApiGenericElement = i), (this.openApiSemanticElement = u), - Array.isArray(o) && (this.passingOptionsNames = o); + Array.isArray(o) && (this.passingOptionsNames = o)); } retrievePassingOptions() { return Ad(this.passingOptionsNames, this); @@ -46267,11 +46321,11 @@ specificationExtensionPredicate: u, ..._ }) { - super({ ..._ }), + (super({ ..._ }), (this.specPath = s), (this.ignoredFields = o || []), 'boolean' == typeof i && (this.canSupportSpecificationExtensions = i), - 'function' == typeof u && (this.specificationExtensionPredicate = u); + 'function' == typeof u && (this.specificationExtensionPredicate = u)); } ObjectElement(s) { const o = this.specPath(s), @@ -46285,9 +46339,9 @@ ) { const i = this.toRefractedElement([...o, 'fixedFields', serializers_value(u)], s), w = new Cu.Pr(cloneDeep(u), i); - this.copyMetaAndAttributes(_, w), + (this.copyMetaAndAttributes(_, w), w.classes.push('fixed-field'), - this.element.content.push(w); + this.element.content.push(w)); } else if ( this.canSupportSpecificationExtensions && this.specificationExtensionPredicate(_) @@ -46305,10 +46359,10 @@ }; class OpenApi3_0Visitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Oh()), (this.specPath = Tl(['document', 'objects', 'OpenApi'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { return im.prototype.ObjectElement.call(this, s); @@ -46318,7 +46372,7 @@ class OpenapiVisitor extends Mixin(nm, rm) { StringElement(s) { const o = new wh(serializers_value(s)); - return this.copyMetaAndAttributes(s, o), (this.element = o), Ju; + return (this.copyMetaAndAttributes(s, o), (this.element = o), Ju); } } const lm = OpenapiVisitor; @@ -46333,10 +46387,10 @@ }; class InfoVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new rh()), (this.specPath = Tl(['document', 'objects', 'Info'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const um = InfoVisitor; @@ -46344,34 +46398,36 @@ StringElement(s) { const o = super.enter(s); return ( - this.element.classes.push('api-version'), this.element.classes.push('version'), o + this.element.classes.push('api-version'), + this.element.classes.push('version'), + o ); } }; class ContactVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Gp()), (this.specPath = Tl(['document', 'objects', 'Contact'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const hm = ContactVisitor; class LicenseVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new uh()), (this.specPath = Tl(['document', 'objects', 'License'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const dm = LicenseVisitor; class LinkVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new dh()), (this.specPath = Tl(['document', 'objects', 'Link'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -46386,13 +46442,13 @@ const mm = class OperationRefVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; const gm = class OperationIdVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; const ym = class PatternedFieldsVisitor_PatternedFieldsVisitor extends nm { @@ -46409,12 +46465,12 @@ specificationExtensionPredicate: _, ...w }) { - super({ ...w }), + (super({ ...w }), (this.specPath = s), (this.ignoredFields = o || []), 'function' == typeof i && (this.fieldPatternPredicate = i), 'boolean' == typeof u && (this.canSupportSpecificationExtensions = u), - 'function' == typeof _ && (this.specificationExtensionPredicate = _); + 'function' == typeof _ && (this.specificationExtensionPredicate = _)); } ObjectElement(s) { return ( @@ -46432,9 +46488,9 @@ const u = this.specPath(s), _ = this.toRefractedElement(u, s), w = new Cu.Pr(cloneDeep(o), _); - this.copyMetaAndAttributes(i, w), + (this.copyMetaAndAttributes(i, w), w.classes.push('patterned-field'), - this.element.content.push(w); + this.element.content.push(w)); } else this.ignoredFields.includes(serializers_value(o)) || this.element.content.push(cloneDeep(i)); @@ -46446,47 +46502,47 @@ }; const vm = class MapVisitor_MapVisitor extends ym { constructor(s) { - super(s), (this.fieldPatternPredicate = Vd); + (super(s), (this.fieldPatternPredicate = Vd)); } }; class LinkParameters extends Cu.Sh { static primaryClass = 'link-parameters'; constructor(s, o, i) { - super(s, o, i), this.classes.push(LinkParameters.primaryClass); + (super(s, o, i), this.classes.push(LinkParameters.primaryClass)); } } const bm = LinkParameters; class ParametersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), (this.element = new bm()), (this.specPath = Tl(['value'])); + (super(s), (this.element = new bm()), (this.specPath = Tl(['value']))); } } const _m = ParametersVisitor; class ServerVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Gf()), (this.specPath = Tl(['document', 'objects', 'Server'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Em = ServerVisitor; const wm = class UrlVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('server-url'), o; + return (this.element.classes.push('server-url'), o); } }; class Servers extends Cu.wE { static primaryClass = 'servers'; constructor(s, o, i) { - super(s, o, i), this.classes.push(Servers.primaryClass); + (super(s, o, i), this.classes.push(Servers.primaryClass)); } } const Sm = Servers; class ServersVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new Sm()); + (super(s), (this.element = new Sm())); } ArrayElement(s) { return ( @@ -46503,46 +46559,46 @@ const xm = ServersVisitor; class ServerVariableVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Xf()), (this.specPath = Tl(['document', 'objects', 'ServerVariable'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const km = ServerVariableVisitor; class ServerVariables extends Cu.Sh { static primaryClass = 'server-variables'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ServerVariables.primaryClass); + (super(s, o, i), this.classes.push(ServerVariables.primaryClass)); } } const Cm = ServerVariables; class VariablesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cm()), - (this.specPath = Tl(['document', 'objects', 'ServerVariable'])); + (this.specPath = Tl(['document', 'objects', 'ServerVariable']))); } } const Om = VariablesVisitor; class MediaTypeVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new fh()), (this.specPath = Tl(['document', 'objects', 'MediaType'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Am = MediaTypeVisitor; const jm = class AlternatingVisitor_AlternatingVisitor extends nm { alternator; constructor({ alternator: s, ...o }) { - super({ ...o }), (this.alternator = s || []); + (super({ ...o }), (this.alternator = s || [])); } enter(s) { const o = this.alternator.map(({ predicate: s, specPath: o }) => lf(s, Tl(o), Nl)), i = xf(o)(s); - return (this.element = this.toRefractedElement(i, s)), Ju; + return ((this.element = this.toRefractedElement(i, s)), Ju); } }, Im = helpers( @@ -46678,33 +46734,34 @@ ); class SchemaVisitor extends Mixin(jm, rm) { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'Schema'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } } const ng = SchemaVisitor; class ExamplesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('examples'), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] : ['document', 'objects', 'Example']), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -46720,48 +46777,48 @@ class MediaTypeExamples extends Cu.Sh { static primaryClass = 'media-type-examples'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(MediaTypeExamples.primaryClass), - this.classes.push('examples'); + this.classes.push('examples')); } } const og = MediaTypeExamples; const lg = class ExamplesVisitor_ExamplesVisitor extends sg { constructor(s) { - super(s), (this.element = new og()); + (super(s), (this.element = new og())); } }; class MediaTypeEncoding extends Cu.Sh { static primaryClass = 'media-type-encoding'; constructor(s, o, i) { - super(s, o, i), this.classes.push(MediaTypeEncoding.primaryClass); + (super(s, o, i), this.classes.push(MediaTypeEncoding.primaryClass)); } } const pg = MediaTypeEncoding; class EncodingVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new pg()), - (this.specPath = Tl(['document', 'objects', 'Encoding'])); + (this.specPath = Tl(['document', 'objects', 'Encoding']))); } } const fg = EncodingVisitor; class SecurityRequirementVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), (this.element = new Hf()), (this.specPath = Tl(['value'])); + (super(s), (this.element = new Hf()), (this.specPath = Tl(['value']))); } } const mg = SecurityRequirementVisitor; class Security extends Cu.wE { static primaryClass = 'security'; constructor(s, o, i) { - super(s, o, i), this.classes.push(Security.primaryClass); + (super(s, o, i), this.classes.push(Security.primaryClass)); } } const gg = Security; class SecurityVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new gg()); + (super(s), (this.element = new gg())); } ArrayElement(s) { return ( @@ -46782,47 +46839,47 @@ const yg = SecurityVisitor; class ComponentsVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Jp()), (this.specPath = Tl(['document', 'objects', 'Components'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const _g = ComponentsVisitor; class TagVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Qf()), (this.specPath = Tl(['document', 'objects', 'Tag'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const xg = TagVisitor; class ReferenceVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Dh()), (this.specPath = Tl(['document', 'objects', 'Reference'])), - (this.canSupportSpecificationExtensions = !1); + (this.canSupportSpecificationExtensions = !1)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); - return Ru(this.element.$ref) && this.element.classes.push('reference-element'), o; + return (Ru(this.element.$ref) && this.element.classes.push('reference-element'), o); } } const kg = ReferenceVisitor; const qg = class $RefVisitor_$RefVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; class ParameterVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ih()), (this.specPath = Tl(['document', 'objects', 'Parameter'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -46838,47 +46895,49 @@ const Vg = ParameterVisitor; class SchemaVisitor_SchemaVisitor extends Mixin(jm, rm) { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'Schema'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } } const Ug = SchemaVisitor_SchemaVisitor; class HeaderVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new th()), (this.specPath = Tl(['document', 'objects', 'Header'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const zg = HeaderVisitor; class header_SchemaVisitor_SchemaVisitor extends Mixin(jm, rm) { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'Schema'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } } @@ -46886,46 +46945,46 @@ class HeaderExamples extends Cu.Sh { static primaryClass = 'header-examples'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(HeaderExamples.primaryClass), - this.classes.push('examples'); + this.classes.push('examples')); } } const Kg = HeaderExamples; const Yg = class header_ExamplesVisitor_ExamplesVisitor extends sg { constructor(s) { - super(s), (this.element = new Kg()); + (super(s), (this.element = new Kg())); } }; class ContentVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('content'), - (this.specPath = Tl(['document', 'objects', 'MediaType'])); + (this.specPath = Tl(['document', 'objects', 'MediaType']))); } } const Xg = ContentVisitor; class HeaderContent extends Cu.Sh { static primaryClass = 'header-content'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(HeaderContent.primaryClass), - this.classes.push('content'); + this.classes.push('content')); } } const Zg = HeaderContent; const ey = class ContentVisitor_ContentVisitor extends Xg { constructor(s) { - super(s), (this.element = new Zg()); + (super(s), (this.element = new Zg())); } }; class schema_SchemaVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Wf()), (this.specPath = Tl(['document', 'objects', 'Schema'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const ty = schema_SchemaVisitor, @@ -46970,7 +47029,8 @@ ObjectElement(s) { const o = ly.prototype.ObjectElement.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } ArrayElement(s) { @@ -47000,84 +47060,85 @@ ObjectElement(s) { const o = fy.prototype.enter.call(this, s); return ( - Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), o + Wm(this.element) && this.element.setMetaProperty('referenced-element', 'schema'), + o ); } }; class DiscriminatorVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Yp()), (this.specPath = Tl(['document', 'objects', 'Discriminator'])), - (this.canSupportSpecificationExtensions = !1); + (this.canSupportSpecificationExtensions = !1)); } } const gy = DiscriminatorVisitor; class DiscriminatorMapping extends Cu.Sh { static primaryClass = 'discriminator-mapping'; constructor(s, o, i) { - super(s, o, i), this.classes.push(DiscriminatorMapping.primaryClass); + (super(s, o, i), this.classes.push(DiscriminatorMapping.primaryClass)); } } const yy = DiscriminatorMapping; class MappingVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), (this.element = new yy()), (this.specPath = Tl(['value'])); + (super(s), (this.element = new yy()), (this.specPath = Tl(['value']))); } } const vy = MappingVisitor; class XmlVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new em()), (this.specPath = Tl(['document', 'objects', 'XML'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const by = XmlVisitor; class ParameterExamples extends Cu.Sh { static primaryClass = 'parameter-examples'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ParameterExamples.primaryClass), - this.classes.push('examples'); + this.classes.push('examples')); } } const _y = ParameterExamples; const Ey = class parameter_ExamplesVisitor_ExamplesVisitor extends sg { constructor(s) { - super(s), (this.element = new _y()); + (super(s), (this.element = new _y())); } }; class ParameterContent extends Cu.Sh { static primaryClass = 'parameter-content'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ParameterContent.primaryClass), - this.classes.push('content'); + this.classes.push('content')); } } const wy = ParameterContent; const Sy = class parameter_ContentVisitor_ContentVisitor extends Xg { constructor(s) { - super(s), (this.element = new wy()); + (super(s), (this.element = new wy())); } }; class ComponentsSchemas extends Cu.Sh { static primaryClass = 'components-schemas'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsSchemas.primaryClass); + (super(s, o, i), this.classes.push(ComponentsSchemas.primaryClass)); } } const xy = ComponentsSchemas; class SchemasVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new xy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Schema']); + : ['document', 'objects', 'Schema'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47093,18 +47154,18 @@ class ComponentsResponses extends Cu.Sh { static primaryClass = 'components-responses'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsResponses.primaryClass); + (super(s, o, i), this.classes.push(ComponentsResponses.primaryClass)); } } const Cy = ComponentsResponses; class ResponsesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Response']); + : ['document', 'objects', 'Response'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47123,20 +47184,20 @@ class ComponentsParameters extends Cu.Sh { static primaryClass = 'components-parameters'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ComponentsParameters.primaryClass), - this.classes.push('parameters'); + this.classes.push('parameters')); } } const Ay = ComponentsParameters; class ParametersVisitor_ParametersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ay()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Parameter']); + : ['document', 'objects', 'Parameter'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47152,20 +47213,20 @@ class ComponentsExamples extends Cu.Sh { static primaryClass = 'components-examples'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ComponentsExamples.primaryClass), - this.classes.push('examples'); + this.classes.push('examples')); } } const Iy = ComponentsExamples; class components_ExamplesVisitor_ExamplesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Iy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Example']); + : ['document', 'objects', 'Example'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47181,18 +47242,18 @@ class ComponentsRequestBodies extends Cu.Sh { static primaryClass = 'components-request-bodies'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsRequestBodies.primaryClass); + (super(s, o, i), this.classes.push(ComponentsRequestBodies.primaryClass)); } } const My = ComponentsRequestBodies; class RequestBodiesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new My()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'RequestBody']); + : ['document', 'objects', 'RequestBody'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47208,18 +47269,18 @@ class ComponentsHeaders extends Cu.Sh { static primaryClass = 'components-headers'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsHeaders.primaryClass); + (super(s, o, i), this.classes.push(ComponentsHeaders.primaryClass)); } } const Ny = ComponentsHeaders; class HeadersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ny()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Header']); + : ['document', 'objects', 'Header'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47238,18 +47299,18 @@ class ComponentsSecuritySchemes extends Cu.Sh { static primaryClass = 'components-security-schemes'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsSecuritySchemes.primaryClass); + (super(s, o, i), this.classes.push(ComponentsSecuritySchemes.primaryClass)); } } const Dy = ComponentsSecuritySchemes; class SecuritySchemesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Dy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'SecurityScheme']); + : ['document', 'objects', 'SecurityScheme'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47265,18 +47326,18 @@ class ComponentsLinks extends Cu.Sh { static primaryClass = 'components-links'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsLinks.primaryClass); + (super(s, o, i), this.classes.push(ComponentsLinks.primaryClass)); } } const By = ComponentsLinks; class LinksVisitor_LinksVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new By()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Link']); + : ['document', 'objects', 'Link'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47292,18 +47353,18 @@ class ComponentsCallbacks extends Cu.Sh { static primaryClass = 'components-callbacks'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsCallbacks.primaryClass); + (super(s, o, i), this.classes.push(ComponentsCallbacks.primaryClass)); } } const qy = ComponentsCallbacks; class CallbacksVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new qy()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Callback']); + : ['document', 'objects', 'Callback'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47318,15 +47379,16 @@ const $y = CallbacksVisitor; class ExampleVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Zp()), (this.specPath = Tl(['document', 'objects', 'Example'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); return ( - Ru(this.element.externalValue) && this.element.classes.push('reference-element'), o + Ru(this.element.externalValue) && this.element.classes.push('reference-element'), + o ); } } @@ -47334,24 +47396,24 @@ const Uy = class ExternalValueVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; class ExternalDocumentationVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Qp()), (this.specPath = Tl(['document', 'objects', 'ExternalDocumentation'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const zy = ExternalDocumentationVisitor; class encoding_EncodingVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Xp()), (this.specPath = Tl(['document', 'objects', 'Encoding'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -47368,18 +47430,18 @@ class EncodingHeaders extends Cu.Sh { static primaryClass = 'encoding-headers'; constructor(s, o, i) { - super(s, o, i), this.classes.push(EncodingHeaders.primaryClass); + (super(s, o, i), this.classes.push(EncodingHeaders.primaryClass)); } } const Ky = EncodingHeaders; class HeadersVisitor_HeadersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ky()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Header']); + : ['document', 'objects', 'Header'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47399,19 +47461,19 @@ const Hy = HeadersVisitor_HeadersVisitor; class PathsVisitor extends Mixin(ym, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Rh()), (this.specPath = Tl(['document', 'objects', 'PathItem'])), (this.canSupportSpecificationExtensions = !0), - (this.fieldPatternPredicate = es_T); + (this.fieldPatternPredicate = es_T)); } ObjectElement(s) { const o = ym.prototype.ObjectElement.call(this, s); return ( this.element.filter(Um).forEach((s, o) => { - o.classes.push('openapi-path-template'), + (o.classes.push('openapi-path-template'), o.classes.push('path-template'), - s.setMetaProperty('path', cloneDeep(o)); + s.setMetaProperty('path', cloneDeep(o))); }), o ); @@ -47420,9 +47482,9 @@ const Jy = PathsVisitor; class RequestBodyVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Lh()), - (this.specPath = Tl(['document', 'objects', 'RequestBody'])); + (this.specPath = Tl(['document', 'objects', 'RequestBody']))); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -47439,24 +47501,25 @@ class RequestBodyContent extends Cu.Sh { static primaryClass = 'request-body-content'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(RequestBodyContent.primaryClass), - this.classes.push('content'); + this.classes.push('content')); } } const Yy = RequestBodyContent; const Xy = class request_body_ContentVisitor_ContentVisitor extends Xg { constructor(s) { - super(s), (this.element = new Yy()); + (super(s), (this.element = new Yy())); } }; class CallbackVisitor extends Mixin(ym, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Hp()), (this.specPath = Tl(['document', 'objects', 'PathItem'])), (this.canSupportSpecificationExtensions = !0), - (this.fieldPatternPredicate = (s) => /{(?[^}]{1,2083})}/.test(String(s))); + (this.fieldPatternPredicate = (s) => + /{(?[^}]{1,2083})}/.test(String(s)))); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47471,9 +47534,9 @@ const Zy = CallbackVisitor; class ResponseVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Fh()), - (this.specPath = Tl(['document', 'objects', 'Response'])); + (this.specPath = Tl(['document', 'objects', 'Response']))); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); @@ -47494,18 +47557,18 @@ class ResponseHeaders extends Cu.Sh { static primaryClass = 'response-headers'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ResponseHeaders.primaryClass); + (super(s, o, i), this.classes.push(ResponseHeaders.primaryClass)); } } const ev = ResponseHeaders; class response_HeadersVisitor_HeadersVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new ev()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Header']); + : ['document', 'objects', 'Header'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47526,32 +47589,32 @@ class ResponseContent extends Cu.Sh { static primaryClass = 'response-content'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(ResponseContent.primaryClass), - this.classes.push('content'); + this.classes.push('content')); } } const rv = ResponseContent; const nv = class response_ContentVisitor_ContentVisitor extends Xg { constructor(s) { - super(s), (this.element = new rv()); + (super(s), (this.element = new rv())); } }; class ResponseLinks extends Cu.Sh { static primaryClass = 'response-links'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ResponseLinks.primaryClass); + (super(s, o, i), this.classes.push(ResponseLinks.primaryClass)); } } const sv = ResponseLinks; class response_LinksVisitor_LinksVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new sv()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Link']); + : ['document', 'objects', 'Link'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47573,9 +47636,8 @@ for ( var i = Array(s < o ? o - s : 0), u = s < 0 ? o + Math.abs(s) : o - s, _ = 0; _ < u; - ) - (i[_] = _ + s), (_ += 1); + ((i[_] = _ + s), (_ += 1)); return i; }); const av = iv; @@ -47599,7 +47661,7 @@ var w = s ? 1 : 0; return !!i._items[_][w] || (o && (i._items[_][w] = !0), !1); } - return o && (i._items[_] = s ? [!1, !0] : [!0, !1]), !1; + return (o && (i._items[_] = s ? [!1, !0] : [!0, !1]), !1); case 'function': return null !== i._nativeSet ? o @@ -47620,7 +47682,7 @@ } const lv = (function () { function _Set() { - (this._nativeSet = 'function' == typeof Set ? new Set() : null), (this._items = {}); + ((this._nativeSet = 'function' == typeof Set ? new Set() : null), (this._items = {})); } return ( (_Set.prototype.add = function (s) { @@ -47635,7 +47697,7 @@ var cv = _curry2(function difference(s, o) { for (var i = [], u = 0, _ = s.length, w = o.length, x = new lv(), C = 0; C < w; C += 1) x.add(o[C]); - for (; u < _; ) x.add(s[u]) && (i[i.length] = s[u]), (u += 1); + for (; u < _; ) (x.add(s[u]) && (i[i.length] = s[u]), (u += 1)); return i; }); const uv = cv; @@ -47643,18 +47705,18 @@ specPathFixedFields; specPathPatternedFields; constructor({ specPathFixedFields: s, specPathPatternedFields: o, ...i }) { - super({ ...i }), (this.specPathFixedFields = s), (this.specPathPatternedFields = o); + (super({ ...i }), (this.specPathFixedFields = s), (this.specPathPatternedFields = o)); } ObjectElement(s) { const { specPath: o, ignoredFields: i } = this; try { this.specPath = this.specPathFixedFields; const o = this.retrieveFixedFields(this.specPath(s)); - (this.ignoredFields = [...i, ...uv(s.keys(), o)]), + ((this.ignoredFields = [...i, ...uv(s.keys(), o)]), im.prototype.ObjectElement.call(this, s), (this.specPath = this.specPathPatternedFields), (this.ignoredFields = o), - ym.prototype.ObjectElement.call(this, s); + ym.prototype.ObjectElement.call(this, s)); } catch (s) { throw ((this.specPath = o), s); } @@ -47664,7 +47726,7 @@ const pv = MixedFieldsVisitor; class responses_ResponsesVisitor extends Mixin(pv, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Kh()), (this.specPathFixedFields = Tl(['document', 'objects', 'Responses'])), (this.canSupportSpecificationExtensions = !0), @@ -47673,7 +47735,7 @@ ? ['document', 'objects', 'Reference'] : ['document', 'objects', 'Response']), (this.fieldPatternPredicate = (s) => - new RegExp(`^(1XX|2XX|3XX|4XX|5XX|${av(100, 600).join('|')})$`).test(String(s))); + new RegExp(`^(1XX|2XX|3XX|4XX|5XX|${av(100, 600).join('|')})$`).test(String(s)))); } ObjectElement(s) { const o = pv.prototype.ObjectElement.call(this, s); @@ -47693,14 +47755,14 @@ const hv = responses_ResponsesVisitor; class DefaultVisitor extends Mixin(jm, rm) { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'Response'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); @@ -47715,39 +47777,39 @@ const dv = DefaultVisitor; class OperationVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new jh()), - (this.specPath = Tl(['document', 'objects', 'Operation'])); + (this.specPath = Tl(['document', 'objects', 'Operation']))); } } const fv = OperationVisitor; class OperationTags extends Cu.wE { static primaryClass = 'operation-tags'; constructor(s, o, i) { - super(s, o, i), this.classes.push(OperationTags.primaryClass); + (super(s, o, i), this.classes.push(OperationTags.primaryClass)); } } const mv = OperationTags; const gv = class TagsVisitor extends rm { constructor(s) { - super(s), (this.element = new mv()); + (super(s), (this.element = new mv())); } ArrayElement(s) { - return (this.element = this.element.concat(cloneDeep(s))), Ju; + return ((this.element = this.element.concat(cloneDeep(s))), Ju); } }; class OperationParameters extends Cu.wE { static primaryClass = 'operation-parameters'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(OperationParameters.primaryClass), - this.classes.push('parameters'); + this.classes.push('parameters')); } } const yv = OperationParameters; class open_api_3_0_ParametersVisitor_ParametersVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new Cu.wE()), this.element.classes.push('parameters'); + (super(s), (this.element = new Cu.wE()), this.element.classes.push('parameters')); } ArrayElement(s) { return ( @@ -47756,7 +47818,8 @@ ? ['document', 'objects', 'Reference'] : ['document', 'objects', 'Parameter'], i = this.toRefractedElement(o, s); - Wm(i) && i.setMetaProperty('referenced-element', 'parameter'), this.element.push(i); + (Wm(i) && i.setMetaProperty('referenced-element', 'parameter'), + this.element.push(i)); }), this.copyMetaAndAttributes(s, this.element), Ju @@ -47766,19 +47829,19 @@ const vv = open_api_3_0_ParametersVisitor_ParametersVisitor; const bv = class operation_ParametersVisitor_ParametersVisitor extends vv { constructor(s) { - super(s), (this.element = new yv()); + (super(s), (this.element = new yv())); } }; const _v = class RequestBodyVisitor_RequestBodyVisitor extends jm { constructor(s) { - super(s), + (super(s), (this.alternator = [ { predicate: isReferenceLikeElement, specPath: ['document', 'objects', 'Reference'] }, { predicate: es_T, specPath: ['document', 'objects', 'RequestBody'] } - ]); + ])); } ObjectElement(s) { const o = jm.prototype.enter.call(this, s); @@ -47791,19 +47854,19 @@ class OperationCallbacks extends Cu.Sh { static primaryClass = 'operation-callbacks'; constructor(s, o, i) { - super(s, o, i), this.classes.push(OperationCallbacks.primaryClass); + (super(s, o, i), this.classes.push(OperationCallbacks.primaryClass)); } } const Ev = OperationCallbacks; class CallbacksVisitor_CallbacksVisitor extends Mixin(vm, rm) { specPath; constructor(s) { - super(s), + (super(s), (this.element = new Ev()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'Callback']); + : ['document', 'objects', 'Callback'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -47819,15 +47882,15 @@ class OperationSecurity extends Cu.wE { static primaryClass = 'operation-security'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(OperationSecurity.primaryClass), - this.classes.push('security'); + this.classes.push('security')); } } const Sv = OperationSecurity; class SecurityVisitor_SecurityVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new Sv()); + (super(s), (this.element = new Sv())); } ArrayElement(s) { return ( @@ -47845,30 +47908,30 @@ class OperationServers extends Cu.wE { static primaryClass = 'operation-servers'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(OperationServers.primaryClass), - this.classes.push('servers'); + this.classes.push('servers')); } } const kv = OperationServers; const Cv = class ServersVisitor_ServersVisitor extends xm { constructor(s) { - super(s), (this.element = new kv()); + (super(s), (this.element = new kv())); } }; class PathItemVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Ph()), - (this.specPath = Tl(['document', 'objects', 'PathItem'])); + (this.specPath = Tl(['document', 'objects', 'PathItem']))); } ObjectElement(s) { const o = im.prototype.ObjectElement.call(this, s); return ( this.element.filter($m).forEach((s, o) => { const i = cloneDeep(o); - (i.content = serializers_value(i).toUpperCase()), - s.setMetaProperty('http-method', i); + ((i.content = serializers_value(i).toUpperCase()), + s.setMetaProperty('http-method', i)); }), Ru(this.element.$ref) && this.element.classes.push('reference-element'), o @@ -47879,87 +47942,87 @@ const Av = class path_item_$RefVisitor_$RefVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; class PathItemServers extends Cu.wE { static primaryClass = 'path-item-servers'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(PathItemServers.primaryClass), - this.classes.push('servers'); + this.classes.push('servers')); } } const jv = PathItemServers; const Iv = class path_item_ServersVisitor_ServersVisitor extends xm { constructor(s) { - super(s), (this.element = new jv()); + (super(s), (this.element = new jv())); } }; class PathItemParameters extends Cu.wE { static primaryClass = 'path-item-parameters'; constructor(s, o, i) { - super(s, o, i), + (super(s, o, i), this.classes.push(PathItemParameters.primaryClass), - this.classes.push('parameters'); + this.classes.push('parameters')); } } const Pv = PathItemParameters; const Mv = class path_item_ParametersVisitor_ParametersVisitor extends vv { constructor(s) { - super(s), (this.element = new Pv()); + (super(s), (this.element = new Pv())); } }; class SecuritySchemeVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Jf()), (this.specPath = Tl(['document', 'objects', 'SecurityScheme'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Tv = SecuritySchemeVisitor; class OAuthFlowsVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new _h()), (this.specPath = Tl(['document', 'objects', 'OAuthFlows'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Nv = OAuthFlowsVisitor; class OAuthFlowVisitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new vh()), (this.specPath = Tl(['document', 'objects', 'OAuthFlow'])), - (this.canSupportSpecificationExtensions = !0); + (this.canSupportSpecificationExtensions = !0)); } } const Rv = OAuthFlowVisitor; class OAuthFlowScopes extends Cu.Sh { static primaryClass = 'oauth-flow-scopes'; constructor(s, o, i) { - super(s, o, i), this.classes.push(OAuthFlowScopes.primaryClass); + (super(s, o, i), this.classes.push(OAuthFlowScopes.primaryClass)); } } const Dv = OAuthFlowScopes; class ScopesVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), (this.element = new Dv()), (this.specPath = Tl(['value'])); + (super(s), (this.element = new Dv()), (this.specPath = Tl(['value']))); } } const Lv = ScopesVisitor; class Tags extends Cu.wE { static primaryClass = 'tags'; constructor(s, o, i) { - super(s, o, i), this.classes.push(Tags.primaryClass); + (super(s, o, i), this.classes.push(Tags.primaryClass)); } } const Bv = Tags; class TagsVisitor_TagsVisitor extends Mixin(nm, rm) { constructor(s) { - super(s), (this.element = new Bv()); + (super(s), (this.element = new Bv())); } ArrayElement(s) { return ( @@ -48397,7 +48460,7 @@ (s) => (o, i = {}) => es_refractor_refract(o, { specPath: s, ...i }); - (Hp.refract = es_refractor_createRefractor([ + ((Hp.refract = es_refractor_createRefractor([ 'visitors', 'document', 'objects', @@ -48614,7 +48677,7 @@ 'objects', 'XML', '$visitor' - ])); + ]))); const Wv = class Callback_Callback extends Hp {}; const Kv = class Components_Components extends Jp { get pathItems() { @@ -48654,7 +48717,7 @@ class JsonSchemaDialect extends Cu.Om { static default = new JsonSchemaDialect('https://spec.openapis.org/oas/3.1/dialect/base'); constructor(s, o, i) { - super(s, o, i), (this.element = 'jsonSchemaDialect'); + (super(s, o, i), (this.element = 'jsonSchemaDialect')); } } const eb = JsonSchemaDialect; @@ -48680,7 +48743,7 @@ const _b = class Openapi_Openapi extends wh {}; class OpenApi3_1 extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'openApi3_1'), this.classes.push('api'); + (super(s, o, i), (this.element = 'openApi3_1'), this.classes.push('api')); } get openapi() { return this.get('openapi'); @@ -48812,7 +48875,7 @@ }; const Ib = class Paths_Paths extends Rh {}; class Reference_Reference extends Dh {} - Object.defineProperty(Reference_Reference.prototype, 'description', { + (Object.defineProperty(Reference_Reference.prototype, 'description', { get() { return this.get('description'); }, @@ -48829,14 +48892,14 @@ this.set('summary', s); }, enumerable: !0 - }); + })); const Pb = Reference_Reference; const Mb = class RequestBody_RequestBody extends Lh {}; const Rb = class elements_Response_Response extends Fh {}; const Lb = class Responses_Responses extends Kh {}; class elements_Schema_Schema extends Cu.Sh { constructor(s, o, i) { - super(s, o, i), (this.element = 'schema'); + (super(s, o, i), (this.element = 'schema')); } get $schema() { return this.get('$schema'); @@ -49214,14 +49277,14 @@ const n_ = class Xml_Xml extends em {}; class OpenApi3_1Visitor extends Mixin(im, rm) { constructor(s) { - super(s), + (super(s), (this.element = new wb()), (this.specPath = Tl(['document', 'objects', 'OpenApi'])), (this.canSupportSpecificationExtensions = !0), - (this.openApiSemanticElement = this.element); + (this.openApiSemanticElement = this.element)); } ObjectElement(s) { - return (this.openApiGenericElement = s), im.prototype.ObjectElement.call(this, s); + return ((this.openApiGenericElement = s), im.prototype.ObjectElement.call(this, s)); } } const s_ = OpenApi3_1Visitor, @@ -49236,7 +49299,7 @@ } = $v; const i_ = class info_InfoVisitor extends o_ { constructor(s) { - super(s), (this.element = new Qv()); + (super(s), (this.element = new Qv())); } }, { @@ -49250,7 +49313,7 @@ } = $v; const l_ = class contact_ContactVisitor extends a_ { constructor(s) { - super(s), (this.element = new Hv()); + (super(s), (this.element = new Hv())); } }, { @@ -49264,7 +49327,7 @@ } = $v; const u_ = class license_LicenseVisitor extends c_ { constructor(s) { - super(s), (this.element = new tb()); + (super(s), (this.element = new tb())); } }, { @@ -49278,13 +49341,13 @@ } = $v; const h_ = class link_LinkVisitor extends p_ { constructor(s) { - super(s), (this.element = new nb()); + (super(s), (this.element = new nb())); } }; class JsonSchemaDialectVisitor extends Mixin(nm, rm) { StringElement(s) { const o = new eb(serializers_value(s)); - return this.copyMetaAndAttributes(s, o), (this.element = o), Ju; + return (this.copyMetaAndAttributes(s, o), (this.element = o), Ju); } } const d_ = JsonSchemaDialectVisitor, @@ -49299,7 +49362,7 @@ } = $v; const m_ = class server_ServerVisitor extends f_ { constructor(s) { - super(s), (this.element = new e_()); + (super(s), (this.element = new e_())); } }, { @@ -49313,7 +49376,7 @@ } = $v; const y_ = class server_variable_ServerVariableVisitor extends g_ { constructor(s) { - super(s), (this.element = new t_()); + (super(s), (this.element = new t_())); } }, { @@ -49327,7 +49390,7 @@ } = $v; const b_ = class media_type_MediaTypeVisitor extends v_ { constructor(s) { - super(s), (this.element = new pb()); + (super(s), (this.element = new pb())); } }, { @@ -49341,7 +49404,7 @@ } = $v; const w_ = class security_requirement_SecurityRequirementVisitor extends E_ { constructor(s) { - super(s), (this.element = new zb()); + (super(s), (this.element = new zb())); } }, { @@ -49355,7 +49418,7 @@ } = $v; const x_ = class components_ComponentsVisitor extends S_ { constructor(s) { - super(s), (this.element = new Kv()); + (super(s), (this.element = new Kv())); } }, { @@ -49369,7 +49432,7 @@ } = $v; const C_ = class tag_TagVisitor extends k_ { constructor(s) { - super(s), (this.element = new r_()); + (super(s), (this.element = new r_())); } }, { @@ -49383,7 +49446,7 @@ } = $v; const A_ = class reference_ReferenceVisitor extends O_ { constructor(s) { - super(s), (this.element = new Pb()); + (super(s), (this.element = new Pb())); } }, { @@ -49397,7 +49460,7 @@ } = $v; const I_ = class parameter_ParameterVisitor extends j_ { constructor(s) { - super(s), (this.element = new Ob()); + (super(s), (this.element = new Ob())); } }, { @@ -49411,7 +49474,7 @@ } = $v; const M_ = class header_HeaderVisitor extends P_ { constructor(s) { - super(s), (this.element = new Zv()); + (super(s), (this.element = new Zv())); } }, T_ = helpers( @@ -49566,15 +49629,15 @@ }; class open_api_3_1_schema_SchemaVisitor extends Mixin(im, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new qb()), (this.specPath = Tl(['document', 'objects', 'Schema'])), (this.canSupportSpecificationExtensions = !0), (this.jsonSchemaDefaultDialect = eb.default), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ObjectElement(s) { - this.handle$schema(s), this.handle$id(s), (this.parent = this.element); + (this.handle$schema(s), this.handle$id(s), (this.parent = this.element)); const o = im.prototype.ObjectElement.call(this, s); return ( Ru(this.element.$ref) && @@ -49585,7 +49648,7 @@ } BooleanElement(s) { const o = super.enter(s); - return this.element.classes.push('boolean-json-schema'), o; + return (this.element.classes.push('boolean-json-schema'), o); } getJsonSchemaDialect() { let s; @@ -49618,38 +49681,38 @@ ? cloneDeep(this.parent.getMetaProperty('inherited$id', [])) : new Cu.wE(), i = serializers_value(s.get('$id')); - Vd(i) && o.push(i), this.element.setMetaProperty('inherited$id', o); + (Vd(i) && o.push(i), this.element.setMetaProperty('inherited$id', o)); } } const iE = open_api_3_1_schema_SchemaVisitor; const aE = class $vocabularyVisitor extends rm { ObjectElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-$vocabulary'), o; + return (this.element.classes.push('json-schema-$vocabulary'), o); } }; const lE = class $refVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('reference-value'), o; + return (this.element.classes.push('reference-value'), o); } }; class $defsVisitor extends Mixin(vm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-$defs'), (this.specPath = Tl(['document', 'objects', 'Schema'])), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } } const cE = $defsVisitor; class schema_AllOfVisitor_AllOfVisitor extends Mixin(nm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-allOf'), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ArrayElement(s) { return ( @@ -49670,10 +49733,10 @@ const uE = schema_AllOfVisitor_AllOfVisitor; class schema_AnyOfVisitor_AnyOfVisitor extends Mixin(nm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-anyOf'), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ArrayElement(s) { return ( @@ -49694,10 +49757,10 @@ const pE = schema_AnyOfVisitor_AnyOfVisitor; class schema_OneOfVisitor_OneOfVisitor extends Mixin(nm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-oneOf'), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ArrayElement(s) { return ( @@ -49718,20 +49781,20 @@ const hE = schema_OneOfVisitor_OneOfVisitor; class DependentSchemasVisitor extends Mixin(vm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-dependentSchemas'), (this.specPath = Tl(['document', 'objects', 'Schema'])), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } } const dE = DependentSchemasVisitor; class PrefixItemsVisitor extends Mixin(nm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.wE()), this.element.classes.push('json-schema-prefixItems'), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } ArrayElement(s) { return ( @@ -49752,50 +49815,50 @@ const fE = PrefixItemsVisitor; class schema_PropertiesVisitor_PropertiesVisitor extends Mixin(vm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-properties'), (this.specPath = Tl(['document', 'objects', 'Schema'])), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } } const mE = schema_PropertiesVisitor_PropertiesVisitor; class PatternPropertiesVisitor_PatternPropertiesVisitor extends Mixin(vm, oE, rm) { constructor(s) { - super(s), + (super(s), (this.element = new Cu.Sh()), this.element.classes.push('json-schema-patternProperties'), (this.specPath = Tl(['document', 'objects', 'Schema'])), - this.passingOptionsNames.push('parent'); + this.passingOptionsNames.push('parent')); } } const gE = PatternPropertiesVisitor_PatternPropertiesVisitor; const yE = class schema_TypeVisitor_TypeVisitor extends rm { StringElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-type'), o; + return (this.element.classes.push('json-schema-type'), o); } ArrayElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-type'), o; + return (this.element.classes.push('json-schema-type'), o); } }; const vE = class EnumVisitor_EnumVisitor extends rm { ArrayElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-enum'), o; + return (this.element.classes.push('json-schema-enum'), o); } }; const bE = class DependentRequiredVisitor extends rm { ObjectElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-dependentRequired'), o; + return (this.element.classes.push('json-schema-dependentRequired'), o); } }; const _E = class schema_ExamplesVisitor_ExamplesVisitor extends rm { ArrayElement(s) { const o = super.enter(s); - return this.element.classes.push('json-schema-examples'), o; + return (this.element.classes.push('json-schema-examples'), o); } }, { @@ -49809,7 +49872,7 @@ } = $v; const wE = class distriminator_DiscriminatorVisitor extends EE { constructor(s) { - super(s), (this.element = new Jv()), (this.canSupportSpecificationExtensions = !0); + (super(s), (this.element = new Jv()), (this.canSupportSpecificationExtensions = !0)); } }, { @@ -49823,32 +49886,32 @@ } = $v; const xE = class xml_XmlVisitor extends SE { constructor(s) { - super(s), (this.element = new n_()); + (super(s), (this.element = new n_())); } }; class SchemasVisitor_SchemasVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new xy()), - (this.specPath = Tl(['document', 'objects', 'Schema'])); + (this.specPath = Tl(['document', 'objects', 'Schema']))); } } const kE = SchemasVisitor_SchemasVisitor; class ComponentsPathItems extends Cu.Sh { static primaryClass = 'components-path-items'; constructor(s, o, i) { - super(s, o, i), this.classes.push(ComponentsPathItems.primaryClass); + (super(s, o, i), this.classes.push(ComponentsPathItems.primaryClass)); } } const CE = ComponentsPathItems; class PathItemsVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new CE()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'PathItem']); + : ['document', 'objects', 'PathItem'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -49872,7 +49935,7 @@ } = $v; const jE = class example_ExampleVisitor extends AE { constructor(s) { - super(s), (this.element = new Yv()); + (super(s), (this.element = new Yv())); } }, { @@ -49886,7 +49949,7 @@ } = $v; const PE = class external_documentation_ExternalDocumentationVisitor extends IE { constructor(s) { - super(s), (this.element = new Xv()); + (super(s), (this.element = new Xv())); } }, { @@ -49900,7 +49963,7 @@ } = $v; const TE = class open_api_3_1_encoding_EncodingVisitor extends ME { constructor(s) { - super(s), (this.element = new Gv()); + (super(s), (this.element = new Gv())); } }, { @@ -49914,7 +49977,7 @@ } = $v; const RE = class paths_PathsVisitor extends NE { constructor(s) { - super(s), (this.element = new Ib()); + (super(s), (this.element = new Ib())); } }, { @@ -49928,7 +49991,7 @@ } = $v; const LE = class request_body_RequestBodyVisitor extends DE { constructor(s) { - super(s), (this.element = new Mb()); + (super(s), (this.element = new Mb())); } }, { @@ -49942,12 +50005,12 @@ } = $v; const FE = class callback_CallbackVisitor extends BE { constructor(s) { - super(s), + (super(s), (this.element = new Wv()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'PathItem']); + : ['document', 'objects', 'PathItem'])); } ObjectElement(s) { const o = BE.prototype.ObjectElement.call(this, s); @@ -49970,7 +50033,7 @@ } = $v; const $E = class response_ResponseVisitor extends qE { constructor(s) { - super(s), (this.element = new Rb()); + (super(s), (this.element = new Rb())); } }, { @@ -49984,7 +50047,7 @@ } = $v; const UE = class open_api_3_1_responses_ResponsesVisitor extends VE { constructor(s) { - super(s), (this.element = new Lb()); + (super(s), (this.element = new Lb())); } }, { @@ -49998,7 +50061,7 @@ } = $v; const WE = class operation_OperationVisitor extends zE { constructor(s) { - super(s), (this.element = new Sb()); + (super(s), (this.element = new Sb())); } }, { @@ -50012,7 +50075,7 @@ } = $v; const HE = class path_item_PathItemVisitor extends KE { constructor(s) { - super(s), (this.element = new Ab()); + (super(s), (this.element = new Ab())); } }, { @@ -50026,7 +50089,7 @@ } = $v; const GE = class security_scheme_SecuritySchemeVisitor extends JE { constructor(s) { - super(s), (this.element = new Qb()); + (super(s), (this.element = new Qb())); } }, { @@ -50040,7 +50103,7 @@ } = $v; const XE = class oauth_flows_OAuthFlowsVisitor extends YE { constructor(s) { - super(s), (this.element = new yb()); + (super(s), (this.element = new yb())); } }, { @@ -50054,24 +50117,24 @@ } = $v; const QE = class oauth_flow_OAuthFlowVisitor extends ZE { constructor(s) { - super(s), (this.element = new mb()); + (super(s), (this.element = new mb())); } }; class Webhooks extends Cu.Sh { static primaryClass = 'webhooks'; constructor(s, o, i) { - super(s, o, i), this.classes.push(Webhooks.primaryClass); + (super(s, o, i), this.classes.push(Webhooks.primaryClass)); } } const ew = Webhooks; class WebhooksVisitor extends Mixin(vm, rm) { constructor(s) { - super(s), + (super(s), (this.element = new ew()), (this.specPath = (s) => isReferenceLikeElement(s) ? ['document', 'objects', 'Reference'] - : ['document', 'objects', 'PathItem']); + : ['document', 'objects', 'PathItem'])); } ObjectElement(s) { const o = vm.prototype.ObjectElement.call(this, s); @@ -50585,7 +50648,7 @@ (s) => (o, i = {}) => apidom_ns_openapi_3_1_es_refractor_refract(o, { specPath: s, ...i }); - (Wv.refract = apidom_ns_openapi_3_1_es_refractor_createRefractor([ + ((Wv.refract = apidom_ns_openapi_3_1_es_refractor_createRefractor([ 'visitors', 'document', 'objects', @@ -50810,7 +50873,7 @@ 'objects', 'XML', '$visitor' - ])); + ]))); const iw = class NotImplementedError extends Hh {}; const aw = class MediaTypes extends Array { unknownMediaType = 'application/octet-stream'; @@ -50852,11 +50915,11 @@ refSet; errors; constructor({ uri: s, depth: o = 0, refSet: i, value: u }) { - (this.uri = s), + ((this.uri = s), (this.value = u), (this.depth = o), (this.refSet = i), - (this.errors = []); + (this.errors = [])); } }; const uw = class ReferenceSet { @@ -50864,7 +50927,7 @@ refs; circular; constructor({ refs: s = [], circular: o = !1 } = {}) { - (this.refs = []), (this.circular = o), s.forEach(this.add.bind(this)); + ((this.refs = []), (this.circular = o), s.forEach(this.add.bind(this))); } get size() { return this.refs.length; @@ -50893,11 +50956,11 @@ yield* this.refs; } clean() { - this.refs.forEach((s) => { + (this.refs.forEach((s) => { s.refSet = void 0; }), (this.rootRef = void 0), - (this.refs.length = 0); + (this.refs.length = 0)); } }, pw = { @@ -50945,11 +51008,11 @@ return (function _assoc(s, o, i) { if (Yo(s) && aa(i)) { var u = [].concat(i); - return (u[s] = o), u; + return ((u[s] = o), u); } var _ = {}; for (var w in i) _[w] = i[w]; - return (_[s] = o), _; + return ((_[s] = o), _); })(u, o, i); }); const fw = dw; @@ -50979,7 +51042,7 @@ data; parseResult; constructor({ uri: s, mediaType: o = 'text/plain', data: i, parseResult: u }) { - (this.uri = s), (this.mediaType = o), (this.data = i), (this.parseResult = u); + ((this.uri = s), (this.mediaType = o), (this.data = i), (this.parseResult = u)); } get extension() { return Yl(this.uri) @@ -51004,7 +51067,7 @@ const bw = class PluginError extends Ho { plugin; constructor(s, o) { - super(s, { cause: o.cause }), (this.plugin = o.plugin); + (super(s, { cause: o.cause }), (this.plugin = o.plugin)); } }, plugins_filter = async (s, o, i) => { @@ -51029,7 +51092,7 @@ u = !1; if (!Ku(s)) { const o = cloneShallow(s); - o.classes.push('result'), (i = new Mu([o])), (u = !0); + (o.classes.push('result'), (i = new Mu([o])), (u = !0)); } const _ = new vw({ uri: o.resolve.baseURI, @@ -51060,11 +51123,11 @@ fileExtensions: u = [], mediaTypes: _ = [] }) { - (this.name = s), + ((this.name = s), (this.allowEmpty = o), (this.sourceMap = i), (this.fileExtensions = u), - (this.mediaTypes = _); + (this.mediaTypes = _)); } }; const kw = class BinaryParser extends xw { @@ -51081,7 +51144,7 @@ u = new Mu(); if (0 !== i.length) { const s = new Cu.Om(i); - s.classes.push('result'), u.push(s); + (s.classes.push('result'), u.push(s)); } return u; } catch (o) { @@ -51108,7 +51171,7 @@ if (void 0 === i) throw new Ew('"openapi-3-1" dereference strategy is not available.'); const u = new uw(), _ = util_merge(o, { resolve: { internal: !1 }, dereference: { refSet: u } }); - return await i.dereference(s, _), u; + return (await i.dereference(s, _), u); } }; const Aw = class Resolver { @@ -51128,10 +51191,10 @@ redirects: u = 5, withCredentials: _ = !1 } = null != s ? s : {}; - super({ name: o }), + (super({ name: o }), (this.timeout = i), (this.redirects = u), - (this.withCredentials = _); + (this.withCredentials = _)); } canRead(s) { return isHttpUrl(s.uri); @@ -51140,8 +51203,8 @@ const Iw = class ResolveError extends Ho {}; const Pw = class ResolverError extends Iw {}, { AbortController: Mw, AbortSignal: Tw } = globalThis; - void 0 === globalThis.AbortController && (globalThis.AbortController = Mw), - void 0 === globalThis.AbortSignal && (globalThis.AbortSignal = Tw); + (void 0 === globalThis.AbortController && (globalThis.AbortController = Mw), + void 0 === globalThis.AbortSignal && (globalThis.AbortSignal = Tw)); const Nw = class HTTPResolverSwaggerClient extends jw { swaggerHTTPClient = http_http; swaggerHTTPClientConfig; @@ -51150,9 +51213,9 @@ swaggerHTTPClientConfig: o = {}, ...i } = {}) { - super({ ...i, name: 'http-swagger-client' }), + (super({ ...i, name: 'http-swagger-client' }), (this.swaggerHTTPClient = s), - (this.swaggerHTTPClientConfig = o); + (this.swaggerHTTPClientConfig = o)); } getHttpClient() { return this.swaggerHTTPClient; @@ -51180,8 +51243,8 @@ try { i.headers.delete('Content-Type'); } catch { - (i = new Response(i.body, { ...i, headers: new Headers(i.headers) })), - i.headers.delete('Content-Type'); + ((i = new Response(i.body, { ...i, headers: new Headers(i.headers) })), + i.headers.delete('Content-Type')); } return i; }, @@ -51216,7 +51279,7 @@ if (i) return !0; if (!i) try { - return JSON.parse(s.toString()), !0; + return (JSON.parse(s.toString()), !0); } catch (s) { return !1; } @@ -51230,7 +51293,7 @@ if (this.allowEmpty && '' === i.trim()) return o; try { const s = from(JSON.parse(i)); - return s.classes.push('result'), o.push(s), o; + return (s.classes.push('result'), o.push(s), o); } catch (o) { throw new Sw(`Error parsing "${s.uri}"`, { cause: o }); } @@ -51251,7 +51314,7 @@ if (i) return !0; if (!i) try { - return mn.load(s.toString(), { schema: nn }), !0; + return (mn.load(s.toString(), { schema: nn }), !0); } catch (s) { return !1; } @@ -51268,7 +51331,7 @@ const s = mn.load(i, { schema: nn }); if (this.allowEmpty && void 0 === s) return o; const u = from(s); - return u.classes.push('result'), o.push(u), o; + return (u.classes.push('result'), o.push(u), o); } catch (o) { throw new Sw(`Error parsing "${s.uri}"`, { cause: o }); } @@ -51294,7 +51357,7 @@ if (!i) try { const o = s.toString(); - return JSON.parse(o), this.detectionRegExp.test(o); + return (JSON.parse(o), this.detectionRegExp.test(o)); } catch (s) { return !1; } @@ -51311,7 +51374,7 @@ try { const s = JSON.parse(i), u = wb.refract(s, this.refractorOpts); - return u.classes.push('result'), o.push(u), o; + return (u.classes.push('result'), o.push(u), o); } catch (o) { throw new Sw(`Error parsing "${s.uri}"`, { cause: o }); } @@ -51338,7 +51401,7 @@ if (!i) try { const o = s.toString(); - return mn.load(o), this.detectionRegExp.test(o); + return (mn.load(o), this.detectionRegExp.test(o)); } catch (s) { return !1; } @@ -51355,7 +51418,7 @@ const s = mn.load(i, { schema: nn }); if (this.allowEmpty && void 0 === s) return o; const u = wb.refract(s, this.refractorOpts); - return u.classes.push('result'), o.push(u), o; + return (u.classes.push('result'), o.push(u), o); } catch (o) { throw new Sw(`Error parsing "${s.uri}"`, { cause: o }); } @@ -51378,14 +51441,14 @@ const zw = class ElementIdentityError extends Jo { value; constructor(s, o) { - super(s, o), void 0 !== o && (this.value = o.value); + (super(s, o), void 0 !== o && (this.value = o.value)); } }; class IdentityManager { uuid; identityMap; constructor({ length: s = 6 } = {}) { - (this.uuid = new Uw({ length: s })), (this.identityMap = new WeakMap()); + ((this.uuid = new Uw({ length: s })), (this.identityMap = new WeakMap())); } identify(s) { if (!Nu(s)) @@ -51397,7 +51460,7 @@ return s.id; if (this.identityMap.has(s)) return this.identityMap.get(s); const o = new Cu.Om(this.generateId()); - return this.identityMap.set(s, o), o; + return (this.identityMap.set(s, o), o); } forget(s) { return !!this.identityMap.has(s) && (this.identityMap.delete(s), !0); @@ -51412,7 +51475,7 @@ }), traversal_find = (s, o) => { const i = new PredicateVisitor({ predicate: s, returnOnTrue: Ju }); - return visitor_visit(o, i), Ww(void 0, [0], i.result); + return (visitor_visit(o, i), Ww(void 0, [0], i.result)); }; const Kw = class JsonSchema$anchorError extends Ho {}; const Hw = class EvaluationJsonSchema$anchorError extends Kw {}; @@ -51437,7 +51500,7 @@ }, traversal_filter = (s, o) => { const i = new PredicateVisitor({ predicate: s }); - return visitor_visit(o, i), new Cu.G6(i.result); + return (visitor_visit(o, i), new Cu.G6(i.result)); }; const Gw = class JsonSchemaUriError extends Ho {}; const Yw = class EvaluationJsonSchemaUriError extends Gw {}, @@ -51454,7 +51517,7 @@ refractToSchemaElement = (s) => { if (refractToSchemaElement.cache.has(s)) return refractToSchemaElement.cache.get(s); const o = qb.refract(s); - return refractToSchemaElement.cache.set(s, o), o; + return (refractToSchemaElement.cache.set(s, o), o); }; refractToSchemaElement.cache = new WeakMap(); const maybeRefractToSchemaElement = (s) => @@ -51555,12 +51618,12 @@ ancestors: _ = new AncestorLineage(), refractCache: w = new Map() }) { - (this.indirections = u), + ((this.indirections = u), (this.namespace = o), (this.reference = s), (this.options = i), (this.ancestors = new AncestorLineage(..._)), - (this.refractCache = w); + (this.refractCache = w)); } toBaseURI(s) { return resolve(this.reference.uri, sanitize(stripHash(s))); @@ -51610,11 +51673,11 @@ i = `${o}-${serializers_value(tS.identify(z))}`; if (this.refractCache.has(i)) z = this.refractCache.get(i); else if (isReferenceLikeElement(z)) - (z = Pb.refract(z)), + ((z = Pb.refract(z)), z.setMetaProperty('referenced-element', o), - this.refractCache.set(i, z); + this.refractCache.set(i, z)); else { - (z = this.namespace.getElementClass(o).refract(z)), this.refractCache.set(i, z); + ((z = this.namespace.getElementClass(o).refract(z)), this.refractCache.set(i, z)); } } if (s === z) throw new Ho('Recursive Reference Object detected'); @@ -51642,7 +51705,7 @@ ? Y : this.options.dereference.circularReplacer )(o); - return w.replaceWith(u, mutationReplacer), !i && u; + return (w.replaceWith(u, mutationReplacer), !i && u); } } const ee = stripHash($.refSet.rootRef.uri) !== $.uri, @@ -51657,11 +51720,11 @@ refractCache: this.refractCache, ancestors: x }); - (z = await eS(z, o, { + ((z = await eS(z, o, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - C.delete(s); + C.delete(s)); } this.indirections.pop(); const ae = cloneShallow(z); @@ -51731,7 +51794,7 @@ ? Y : this.options.dereference.circularReplacer )(o); - return w.replaceWith(u, mutationReplacer), !i && u; + return (w.replaceWith(u, mutationReplacer), !i && u); } } const ee = stripHash($.refSet.rootRef.uri) !== $.uri, @@ -51746,25 +51809,25 @@ refractCache: this.refractCache, ancestors: x }); - (z = await eS(z, o, { + ((z = await eS(z, o, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - C.delete(s); + C.delete(s)); } if ((this.indirections.pop(), H_(z))) { const o = new Ab([...z.content], cloneDeep(z.meta), cloneDeep(z.attributes)); - o.setMetaProperty('id', tS.generateId()), + (o.setMetaProperty('id', tS.generateId()), s.forEach((s, i, u) => { - o.remove(serializers_value(i)), o.content.push(u); + (o.remove(serializers_value(i)), o.content.push(u)); }), o.remove('$ref'), o.setMetaProperty('ref-fields', { $ref: serializers_value(s.$ref) }), o.setMetaProperty('ref-origin', $.uri), o.setMetaProperty('ref-referencing-element-id', cloneDeep(tS.identify(s))), - (z = o); + (z = o)); } - return w.replaceWith(z, mutationReplacer), i ? void 0 : z; + return (w.replaceWith(z, mutationReplacer), i ? void 0 : z); } async LinkElement(s, o, i, u, _, w) { if (!Ru(s.operationRef) && !Ru(s.operationId)) return; @@ -51788,7 +51851,7 @@ ? (x = this.refractCache.get(s)) : ((x = Sb.refract(x)), this.refractCache.set(s, x)); } - (x = cloneShallow(x)), x.setMetaProperty('ref-origin', L.uri); + ((x = cloneShallow(x)), x.setMetaProperty('ref-origin', L.uri)); const B = cloneShallow(s); return ( null === (C = B.operationRef) || void 0 === C || C.meta.set('operation', x), @@ -51829,7 +51892,7 @@ B = cloneShallow(L.value.result); B.setMetaProperty('ref-origin', L.uri); const $ = cloneShallow(s); - return ($.value = B), w.replaceWith($, mutationReplacer), i ? void 0 : $; + return (($.value = B), w.replaceWith($, mutationReplacer), i ? void 0 : $); } async SchemaElement(s, o, i, u, _, w) { if (!Ru(s.$ref)) return; @@ -51871,9 +51934,9 @@ j = await this.toReference(unsanitize(B)); const s = uriToPointer(B), o = maybeRefractToSchemaElement(j.value.result); - (Y = es_evaluate(s, o)), + ((Y = es_evaluate(s, o)), (Y = maybeRefractToSchemaElement(Y)), - (Y.id = tS.identify(Y)); + (Y.id = tS.identify(Y))); } } catch (s) { if (!(z && s instanceof Yw)) throw s; @@ -51888,9 +51951,9 @@ j = await this.toReference(unsanitize(B)); const s = uriToAnchor(B), o = maybeRefractToSchemaElement(j.value.result); - (Y = $anchor_evaluate(s, o)), + ((Y = $anchor_evaluate(s, o)), (Y = maybeRefractToSchemaElement(Y)), - (Y.id = tS.identify(Y)); + (Y.id = tS.identify(Y))); } else { if ( ((L = this.toBaseURI(B)), @@ -51903,9 +51966,9 @@ j = await this.toReference(unsanitize(B)); const s = uriToPointer(B), o = maybeRefractToSchemaElement(j.value.result); - (Y = es_evaluate(s, o)), + ((Y = es_evaluate(s, o)), (Y = maybeRefractToSchemaElement(Y)), - (Y.id = tS.identify(Y)); + (Y.id = tS.identify(Y))); } } if (s === Y) throw new Ho('Recursive Schema Object reference detected'); @@ -51933,7 +51996,7 @@ ? ie : this.options.dereference.circularReplacer )(o); - return w.replaceWith(u, mutationReplacer), !i && u; + return (w.replaceWith(u, mutationReplacer), !i && u); } } const le = stripHash(j.refSet.rootRef.uri) !== j.uri, @@ -51948,11 +52011,11 @@ refractCache: this.refractCache, ancestors: x }); - (Y = await eS(Y, o, { + ((Y = await eS(Y, o, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - C.delete(s); + C.delete(s)); } if ((this.indirections.pop(), predicates_isBooleanJsonSchemaElement(Y))) { const o = cloneDeep(Y); @@ -51967,17 +52030,17 @@ } if (Q_(Y)) { const o = new qb([...Y.content], cloneDeep(Y.meta), cloneDeep(Y.attributes)); - o.setMetaProperty('id', tS.generateId()), + (o.setMetaProperty('id', tS.generateId()), s.forEach((s, i, u) => { - o.remove(serializers_value(i)), o.content.push(u); + (o.remove(serializers_value(i)), o.content.push(u)); }), o.remove('$ref'), o.setMetaProperty('ref-fields', { $ref: serializers_value(s.$ref) }), o.setMetaProperty('ref-origin', j.uri), o.setMetaProperty('ref-referencing-element-id', cloneDeep(tS.identify(s))), - (Y = o); + (Y = o)); } - return w.replaceWith(Y, mutationReplacer), i ? void 0 : Y; + return (w.replaceWith(Y, mutationReplacer), i ? void 0 : Y); } } const rS = OpenAPI3_1DereferenceVisitor, @@ -51999,7 +52062,7 @@ w = new uw(); let x, C = _; - _.has(s.uri) + (_.has(s.uri) ? (x = _.find(Fw(s.uri, 'uri'))) : ((x = new cw({ uri: s.uri, value: s.parseResult })), _.add(x)), o.dereference.immutable && @@ -52007,7 +52070,7 @@ .map((s) => new cw({ ...s, value: cloneDeep(s.value) })) .forEach((s) => w.add(s)), (x = w.find((o) => o.uri === s.uri)), - (C = w)); + (C = w))); const j = new rS({ reference: x, namespace: u, options: o }), L = await nS(C.rootRef.value, j, { keyMap: nw, @@ -52053,20 +52116,20 @@ } catch (o) { var u, w; const x = new Error(o, { cause: o }); - (x.fullPath = [...to_path([..._, i, s]), 'properties']), + ((x.fullPath = [...to_path([..._, i, s]), 'properties']), null === (u = this.options.dereference.dereferenceOpts) || void 0 === u || null === (u = u.errors) || void 0 === u || null === (w = u.push) || void 0 === w || - w.call(u, x); + w.call(u, x)); } }); } }; constructor({ modelPropertyMacro: s, options: o }) { - (this.modelPropertyMacro = s), (this.options = o); + ((this.modelPropertyMacro = s), (this.options = o)); } }; const iS = class all_of_AllOfVisitor { @@ -52149,19 +52212,19 @@ } catch (s) { var C, j; const o = new Error(s, { cause: s }); - (o.fullPath = to_path([..._, i])), + ((o.fullPath = to_path([..._, i])), null === (C = this.options.dereference.dereferenceOpts) || void 0 === C || null === (C = C.errors) || void 0 === C || null === (j = C.push) || void 0 === j || - j.call(C, o); + j.call(C, o)); } } }; constructor({ parameterMacro: s, options: o }) { - (this.parameterMacro = s), (this.options = o); + ((this.parameterMacro = s), (this.options = o)); } }, get_root_cause = (s) => { @@ -52187,10 +52250,10 @@ basePath: i = null, ...u }) { - super(u), + (super(u), (this.allowMetaPatches = s), (this.useCircularStructures = o), - (this.basePath = i); + (this.basePath = i)); } async ReferenceElement(s, o, i, u, _, w) { try { @@ -52211,11 +52274,11 @@ i = `${o}-${serializers_value(pS.identify(Y))}`; if (this.refractCache.has(i)) Y = this.refractCache.get(i); else if (isReferenceLikeElement(Y)) - (Y = Pb.refract(Y)), + ((Y = Pb.refract(Y)), Y.setMetaProperty('referenced-element', o), - this.refractCache.set(i, Y); + this.refractCache.set(i, Y)); else { - (Y = this.namespace.getElementClass(o).refract(Y)), this.refractCache.set(i, Y); + ((Y = this.namespace.getElementClass(o).refract(Y)), this.refractCache.set(i, Y)); } } if (s === Y) throw new Ho('Recursive Reference Object detected'); @@ -52245,7 +52308,7 @@ ? x : this.options.dereference.circularReplacer )(o); - return w.replaceWith(o, dereference_mutationReplacer), !i && u; + return (w.replaceWith(o, dereference_mutationReplacer), !i && u); } } const Z = stripHash(V.refSet.rootRef.uri) !== V.uri, @@ -52267,11 +52330,11 @@ ? j : [...to_path([..._, i, s]), '$ref'] }); - (Y = await uS(Y, w, { + ((Y = await uS(Y, w, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - u.delete(s); + u.delete(s)); } this.indirections.pop(); const ie = cloneShallow(Y); @@ -52295,7 +52358,7 @@ const s = resolve(L, U); ie.set('$$ref', s); } - return w.replaceWith(ie, dereference_mutationReplacer), !i && ie; + return (w.replaceWith(ie, dereference_mutationReplacer), !i && ie); } catch (o) { var L, B, $; const u = get_root_cause(o), @@ -52368,7 +52431,7 @@ ? x : this.options.dereference.circularReplacer )(o); - return w.replaceWith(o, dereference_mutationReplacer), !i && u; + return (w.replaceWith(o, dereference_mutationReplacer), !i && u); } } const Z = stripHash(V.refSet.rootRef.uri) !== V.uri, @@ -52389,17 +52452,17 @@ ? j : [...to_path([..._, i, s]), '$ref'] }); - (Y = await uS(Y, w, { + ((Y = await uS(Y, w, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - u.delete(s); + u.delete(s)); } if ((this.indirections.pop(), H_(Y))) { const o = new Ab([...Y.content], cloneDeep(Y.meta), cloneDeep(Y.attributes)); if ( (s.forEach((s, i, u) => { - o.remove(serializers_value(i)), o.content.push(u); + (o.remove(serializers_value(i)), o.content.push(u)); }), o.remove('$ref'), o.setMetaProperty('ref-fields', { $ref: serializers_value(s.$ref) }), @@ -52412,7 +52475,7 @@ } Y = o; } - return w.replaceWith(Y, dereference_mutationReplacer), i ? void 0 : Y; + return (w.replaceWith(Y, dereference_mutationReplacer), i ? void 0 : Y); } catch (o) { var L, B, $; const u = get_root_cause(o), @@ -52477,9 +52540,9 @@ L = await this.toReference(unsanitize($)); const s = uriToPointer($), o = maybeRefractToSchemaElement(L.value.result); - (Z = es_evaluate(s, o)), + ((Z = es_evaluate(s, o)), (Z = maybeRefractToSchemaElement(Z)), - (Z.id = pS.identify(Z)); + (Z.id = pS.identify(Z))); } } catch (s) { if (!(Y && s instanceof Yw)) throw s; @@ -52494,9 +52557,9 @@ L = await this.toReference(unsanitize($)); const s = uriToAnchor($), o = maybeRefractToSchemaElement(L.value.result); - (Z = $anchor_evaluate(s, o)), + ((Z = $anchor_evaluate(s, o)), (Z = maybeRefractToSchemaElement(Z)), - (Z.id = pS.identify(Z)); + (Z.id = pS.identify(Z))); } else { if ( ((B = this.toBaseURI(serializers_value($))), @@ -52509,9 +52572,9 @@ L = await this.toReference(unsanitize($)); const s = uriToPointer($), o = maybeRefractToSchemaElement(L.value.result); - (Z = es_evaluate(s, o)), + ((Z = es_evaluate(s, o)), (Z = maybeRefractToSchemaElement(Z)), - (Z.id = pS.identify(Z)); + (Z.id = pS.identify(Z))); } } if (s === Z) throw new Ho('Recursive Schema Object reference detected'); @@ -52541,7 +52604,7 @@ ? x : this.options.dereference.circularReplacer )(o); - return w.replaceWith(u, dereference_mutationReplacer), !i && u; + return (w.replaceWith(u, dereference_mutationReplacer), !i && u); } } const ae = stripHash(L.refSet.rootRef.uri) !== L.uri, @@ -52562,11 +52625,11 @@ ? j : [...to_path([..._, i, s]), '$ref'] }); - (Z = await uS(Z, w, { + ((Z = await uS(Z, w, { keyMap: nw, nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType })), - u.delete(s); + u.delete(s)); } if ((this.indirections.pop(), predicates_isBooleanJsonSchemaElement(Z))) { const o = cloneDeep(Z); @@ -52582,7 +52645,7 @@ const o = new qb([...Z.content], cloneDeep(Z.meta), cloneDeep(Z.attributes)); if ( (s.forEach((s, i, u) => { - o.remove(serializers_value(i)), o.content.push(u); + (o.remove(serializers_value(i)), o.content.push(u)); }), o.remove('$ref'), o.setMetaProperty('ref-fields', { $ref: serializers_value(s.$ref) }), @@ -52595,7 +52658,7 @@ } Z = o; } - return w.replaceWith(Z, dereference_mutationReplacer), i ? void 0 : Z; + return (w.replaceWith(Z, dereference_mutationReplacer), i ? void 0 : Z); } catch (o) { var L, B, $; const u = get_root_cause(o), @@ -52651,10 +52714,10 @@ const fS = class RootVisitor { constructor({ parameterMacro: s, modelPropertyMacro: o, mode: i, options: u, ..._ }) { const w = []; - w.push(new hS({ ..._, options: u })), + (w.push(new hS({ ..._, options: u })), 'function' == typeof o && w.push(new oS({ modelPropertyMacro: o, options: u })), 'strict' !== i && w.push(new iS({ options: u })), - 'function' == typeof s && w.push(new aS({ parameterMacro: s, options: u })); + 'function' == typeof s && w.push(new aS({ parameterMacro: s, options: u }))); const x = dS(w, { nodeTypeGetter: apidom_ns_openapi_3_1_es_traversal_visitor_getNodeType }); @@ -52676,13 +52739,13 @@ ancestors: _ = [], ...w } = {}) { - super({ ...w }), + (super({ ...w }), (this.name = 'openapi-3-1-swagger-client'), (this.allowMetaPatches = s), (this.parameterMacro = o), (this.modelPropertyMacro = i), (this.mode = u), - (this.ancestors = [..._]); + (this.ancestors = [..._])); } async dereference(s, o) { var i; @@ -52691,7 +52754,7 @@ w = new uw(); let x, C = _; - _.has(s.uri) + (_.has(s.uri) ? (x = _.find((o) => o.uri === s.uri)) : ((x = new cw({ uri: s.uri, value: s.parseResult })), _.add(x)), o.dereference.immutable && @@ -52699,7 +52762,7 @@ .map((s) => new cw({ ...s, value: cloneDeep(s.value) })) .forEach((s) => w.add(s)), (x = w.find((o) => o.uri === s.uri)), - (C = w)); + (C = w))); const j = new fS({ reference: x, namespace: u, @@ -52860,13 +52923,13 @@ } var vS = (function () { function _ObjectMap() { - (this.map = {}), (this.length = 0); + ((this.map = {}), (this.length = 0)); } return ( (_ObjectMap.prototype.set = function (s, o) { var i = this.hash(s), u = this.map[i]; - u || (this.map[i] = u = []), u.push([s, o]), (this.length += 1); + (u || (this.map[i] = u = []), u.push([s, o]), (this.length += 1)); }), (_ObjectMap.prototype.hash = function (s) { var o = []; @@ -52893,11 +52956,11 @@ })(), bS = (function () { function XReduceBy(s, o, i, u) { - (this.valueFn = s), + ((this.valueFn = s), (this.valueAcc = o), (this.keyFn = i), (this.xf = u), - (this.inputs = {}); + (this.inputs = {})); } return ( (XReduceBy.prototype['@@transducer/init'] = _xfBase_init), @@ -52911,7 +52974,7 @@ s = s['@@transducer/value']; break; } - return (this.inputs = null), this.xf['@@transducer/result'](s); + return ((this.inputs = null), this.xf['@@transducer/result'](s)); }), (XReduceBy.prototype['@@transducer/step'] = function (s, o) { var i = this.keyFn(o); @@ -52945,22 +53008,22 @@ _checkForMethod( 'groupBy', _S(function (s, o) { - return s.push(o), s; + return (s.push(o), s); }, []) ) ); const wS = class NormalizeStorage { internalStore; constructor(s, o, i) { - (this.storageElement = s), (this.storageField = o), (this.storageSubField = i); + ((this.storageElement = s), (this.storageField = o), (this.storageSubField = i)); } get store() { if (!this.internalStore) { let s = this.storageElement.get(this.storageField); Fu(s) || ((s = new Cu.Sh()), this.storageElement.set(this.storageField, s)); let o = s.get(this.storageSubField); - qu(o) || ((o = new Cu.wE()), s.set(this.storageSubField, o)), - (this.internalStore = o); + (qu(o) || ((o = new Cu.wE()), s.set(this.storageSubField, o)), + (this.internalStore = o)); } return this.internalStore; } @@ -53002,7 +53065,7 @@ }, leave() { const s = ES((s) => serializers_value(s.operationId), C); - Object.entries(s).forEach(([s, o]) => { + (Object.entries(s).forEach(([s, o]) => { Array.isArray(o) && (o.length <= 1 || o.forEach((o, i) => { @@ -53023,7 +53086,7 @@ }), (C.length = 0), (j.length = 0), - (L = void 0); + (L = void 0)); } }, PathItemElement: { @@ -53062,7 +53125,7 @@ }; var SS = (function () { function XUniqWith(s, o) { - (this.xf = o), (this.pred = s), (this.items = []); + ((this.xf = o), (this.pred = s), (this.items = [])); } return ( (XUniqWith.prototype['@@transducer/init'] = _xfBase_init), @@ -53083,7 +53146,7 @@ var xS = _curry2( _dispatchable([], _xuniqWith, function (s, o) { for (var i, u = 0, _ = o.length, w = []; u < _; ) - _includesWith(s, (i = o[u]), w) || (w[w.length] = i), (u += 1); + (_includesWith(s, (i = o[u]), w) || (w[w.length] = i), (u += 1)); return w; }) ); @@ -53131,7 +53194,7 @@ if (w.includes(L)) return; const B = Ww([], ['parameters', 'content'], s), $ = kS(parameterEquals, [...B, ...j]); - (s.parameters = new yv($)), w.append(L); + ((s.parameters = new yv($)), w.append(L)); } } } @@ -53146,11 +53209,11 @@ visitor: { OpenApi3_1Element: { enter(o) { - (w = new wS(o, s, 'security-requirements')), - i.isArrayElement(o.security) && (_ = o.security); + ((w = new wS(o, s, 'security-requirements')), + i.isArrayElement(o.security) && (_ = o.security)); }, leave() { - (w = void 0), (_ = void 0); + ((w = void 0), (_ = void 0)); } }, OperationElement: { @@ -53299,9 +53362,9 @@ i.classes.push('result'); const u = o(i), _ = serializers_value(u); - return yS.cache.set(_, u), serializers_value(u); + return (yS.cache.set(_, u), serializers_value(u)); })(s); - return (i.$$normalized = !0), i; + return ((i.$$normalized = !0), i); } var o; return Nu(s) ? openapi_3_1_apidom_normalize(s) : s; @@ -53327,7 +53390,7 @@ o = MS, i = this, u = 'parser.js: Parser(): '; - (i.ast = void 0), (i.stats = void 0), (i.trace = void 0), (i.callbacks = []); + ((i.ast = void 0), (i.stats = void 0), (i.trace = void 0), (i.callbacks = [])); let _, w, x, @@ -53341,15 +53404,15 @@ z = 0, Y = 0, Z = new (function systemData() { - (this.state = s.ACTIVE), + ((this.state = s.ACTIVE), (this.phraseLength = 0), (this.refresh = () => { - (this.state = s.ACTIVE), (this.phraseLength = 0); - }); + ((this.state = s.ACTIVE), (this.phraseLength = 0)); + })); })(); i.parse = (ee, ie, ae, le) => { const ce = `${u}parse(): `; - ($ = 0), + (($ = 0), (V = 0), (U = 0), (z = 0), @@ -53364,7 +53427,7 @@ (B = void 0), (C = o.stringToChars(ae)), (_ = ee.rules), - (w = ee.udts); + (w = ee.udts)); const pe = ie.toLowerCase(); let de; for (const s in _) @@ -53374,7 +53437,7 @@ } if (void 0 === de) throw new Error(`${ce}start rule name '${startRule}' not recognized`); - (() => { + ((() => { const s = `${u}initializeCallbacks(): `; let o, x; for (j = [], L = [], o = 0; o < _.length; o += 1) j[o] = void 0; @@ -53402,7 +53465,7 @@ (B = le), (x = [{ type: s.RNM, index: de }]), opExecute(0, 0), - (x = void 0); + (x = void 0)); let fe = !1; switch (Z.state) { case s.ACTIVE: @@ -53432,9 +53495,9 @@ if (i.phraseLength > _) { let s = `${u}opRNM(${o.name}): callback function error: `; throw ( - ((s += `sysData.phraseLength: ${i.phraseLength}`), + (s += `sysData.phraseLength: ${i.phraseLength}`), (s += ` must be <= remaining chars: ${_}`), - new Error(s)) + new Error(s) ); } switch (i.state) { @@ -53463,20 +53526,20 @@ let V, U, z; const Y = x[o], ee = w[Y.index]; - (Z.UdtIndex = ee.index), + ((Z.UdtIndex = ee.index), $ || ((z = i.ast && i.ast.udtDefined(Y.index)), z && - ((U = _.length + Y.index), (V = i.ast.getLength()), i.ast.down(U, ee.name))); + ((U = _.length + Y.index), (V = i.ast.getLength()), i.ast.down(U, ee.name)))); const ie = C.length - j; - L[Y.index](Z, C, j, B), + (L[Y.index](Z, C, j, B), ((o, i, _) => { if (i.phraseLength > _) { let s = `${u}opUDT(${o.name}): callback function error: `; throw ( - ((s += `sysData.phraseLength: ${i.phraseLength}`), + (s += `sysData.phraseLength: ${i.phraseLength}`), (s += ` must be <= remaining chars: ${_}`), - new Error(s)) + new Error(s) ); } switch (i.state) { @@ -53506,7 +53569,7 @@ (z && (Z.state === s.NOMATCH ? i.ast.setLength(V) - : i.ast.up(U, ee.name, j, Z.phraseLength))); + : i.ast.up(U, ee.name, j, Z.phraseLength)))); }, opExecute = (o, w) => { const L = `${u}opExecute(): `, @@ -53534,13 +53597,13 @@ ((o, u) => { let _, w, C, j; const L = x[o]; - i.ast && (w = i.ast.getLength()), (_ = !0), (C = u), (j = 0); + (i.ast && (w = i.ast.getLength()), (_ = !0), (C = u), (j = 0)); for (let o = 0; o < L.children.length; o += 1) { if ((opExecute(L.children[o], C), Z.state === s.NOMATCH)) { _ = !1; break; } - (C += Z.phraseLength), (j += Z.phraseLength); + ((C += Z.phraseLength), (j += Z.phraseLength)); } _ ? ((Z.state = 0 === j ? s.EMPTY : s.MATCH), (Z.phraseLength = j)) @@ -53553,14 +53616,13 @@ ((o, u) => { let _, w, j, L; const B = x[o]; - if (0 === B.max) return (Z.state = s.EMPTY), void (Z.phraseLength = 0); + if (0 === B.max) return ((Z.state = s.EMPTY), void (Z.phraseLength = 0)); for ( w = u, j = 0, L = 0, i.ast && (_ = i.ast.getLength()); !(w >= C.length) && (opExecute(o + 1, w), Z.state !== s.NOMATCH) && Z.state !== s.EMPTY && ((L += 1), (j += Z.phraseLength), (w += Z.phraseLength), L !== B.max); - ); Z.state === s.EMPTY || L >= B.min ? ((Z.state = 0 === j ? s.EMPTY : s.MATCH), (Z.phraseLength = j)) @@ -53582,7 +53644,7 @@ Y) ) { const o = C.length - u; - Y(Z, C, u, B), + (Y(Z, C, u, B), validateRnmCallbackResult(z, Z, o, !0), Z.state === s.ACTIVE && ((V = x), @@ -53590,8 +53652,8 @@ opExecute(0, u), (x = V), Y(Z, C, u, B), - validateRnmCallbackResult(z, Z, o, !1)); - } else (V = x), (x = z.opcodes), opExecute(0, u, Z), (x = V); + validateRnmCallbackResult(z, Z, o, !1))); + } else ((V = x), (x = z.opcodes), opExecute(0, u, Z), (x = V)); $ || (L && (Z.state === s.NOMATCH @@ -53602,11 +53664,11 @@ case s.TRG: ((o, i) => { const u = x[o]; - (Z.state = s.NOMATCH), + ((Z.state = s.NOMATCH), i < C.length && u.min <= C[i] && C[i] <= u.max && - ((Z.state = s.MATCH), (Z.phraseLength = 1)); + ((Z.state = s.MATCH), (Z.phraseLength = 1))); })(o, w); break; case s.TBS: @@ -53615,7 +53677,7 @@ _ = u.string.length; if (((Z.state = s.NOMATCH), i + _ <= C.length)) { for (let s = 0; s < _; s += 1) if (C[i + s] !== u.string[s]) return; - (Z.state = s.MATCH), (Z.phraseLength = _); + ((Z.state = s.MATCH), (Z.phraseLength = _)); } })(o, w); break; @@ -53632,7 +53694,7 @@ ((u = C[i + s]), u >= 65 && u <= 90 && (u += 32), u !== _.string[s]) ) return; - (Z.state = s.MATCH), (Z.phraseLength = w); + ((Z.state = s.MATCH), (Z.phraseLength = w)); } } else Z.state = s.EMPTY; })(o, w); @@ -53677,10 +53739,10 @@ default: throw new Error(`${L}unrecognized operator`); } - $ || (w + Z.phraseLength > Y && (Y = w + Z.phraseLength)), + ($ || (w + Z.phraseLength > Y && (Y = w + Z.phraseLength)), i.stats && i.stats.collect(ee, Z), i.trace && i.trace.up(ee, Z.state, w, Z.phraseLength), - (V -= 1); + (V -= 1)); }; }, PS = function fnast() { @@ -53699,10 +53761,10 @@ for (; s-- > 0; ) o += ' '; return o; } - (i.callbacks = []), + ((i.callbacks = []), (i.init = (s, o, B) => { let $; - (j.length = 0), (L.length = 0), (x = 0), (u = s), (_ = o), (w = B); + ((j.length = 0), (L.length = 0), (x = 0), (u = s), (_ = o), (w = B)); const V = []; for ($ = 0; $ < u.length; $ += 1) V.push(u[$].lower); for ($ = 0; $ < _.length; $ += 1) V.push(_[$].lower); @@ -53759,15 +53821,15 @@ (i.translate = (o) => { let i, u; for (let _ = 0; _ < L.length; _ += 1) - (u = L[_]), + ((u = L[_]), (i = C[u.callbackIndex]), i && (u.state === s.SEM_PRE ? i(s.SEM_PRE, w, u.phraseIndex, u.phraseLength, o) - : i && i(s.SEM_POST, w, u.phraseIndex, u.phraseLength, o)); + : i && i(s.SEM_POST, w, u.phraseIndex, u.phraseLength, o))); }), (i.setLength = (s) => { - (L.length = s), (j.length = s > 0 ? L[s - 1].stack : 0); + ((L.length = s), (j.length = s > 0 ? L[s - 1].stack : 0)); }), (i.getLength = () => L.length), (i.toXml = () => { @@ -53795,7 +53857,7 @@ (i += '\n'), i ); - }); + })); }, MS = { stringToChars: (s) => [...s].map((s) => s.codePointAt(0)), @@ -53901,7 +53963,7 @@ return TS.SEM_OK; }, NS = new (function grammar() { - (this.grammarObject = 'grammarObject'), + ((this.grammarObject = 'grammarObject'), (this.rules = []), (this.rules[0] = { name: 'server-url-template', @@ -54078,15 +54140,15 @@ (s += 'iprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD\n'), '; OpenAPI Server URL templating ABNF syntax\nserver-url-template = 1*( literals / server-variable )\nserver-variable = "{" server-variable-name "}"\nserver-variable-name = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" )\nliterals = 1*( %x21 / %x23-24 / %x26 / %x28-3B / %x3D / %x3F-5B\n / %x5D / %x5F / %x61-7A / %x7E / ucschar / iprivate\n / pct-encoded)\n ; any Unicode character except: CTL, SP,\n ; DQUOTE, "\'", "%" (aside from pct-encoded),\n ; "<", ">", "\\", "^", "`", "{", "|", "}"\n\n; Characters definitions (from RFC 6570)\nALPHA = %x41-5A / %x61-7A ; A-Z / a-z\nDIGIT = %x30-39 ; 0-9\nHEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"\n ; case-insensitive\n\npct-encoded = "%" HEXDIG HEXDIG\nunreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"\nsub-delims = "!" / "$" / "&" / "\'" / "(" / ")"\n / "*" / "+" / "," / ";" / "="\n\nucschar = %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF\n / %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD\n / %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD\n / %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD\n / %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD\n / %xD0000-DFFFD / %xE1000-EFFFD\n\niprivate = %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD\n' ); - }); + })); })(), openapi_server_url_templating_es_parse = (s) => { const o = new IS(); - (o.ast = new PS()), + ((o.ast = new PS()), (o.ast.callbacks['server-url-template'] = server_url_template), (o.ast.callbacks['server-variable'] = callbacks_server_variable), (o.ast.callbacks['server-variable-name'] = server_variable_name), - (o.ast.callbacks.literals = callbacks_literals); + (o.ast.callbacks.literals = callbacks_literals)); return { result: o.parse(NS, 'server-url-template', s), ast: o.ast }; }, openapi_server_url_templating_es_test = (s, { strict: o = !1 } = {}) => { @@ -54098,7 +54160,7 @@ const _ = u.some(([s]) => 'server-variable' === s); if (!o && !_) try { - return new URL(s, 'https://vladimirgorej.com'), !0; + return (new URL(s, 'https://vladimirgorej.com'), !0); } catch { return !1; } @@ -54136,7 +54198,8 @@ return x.join(''); }; const callbacks_slash = (s, o, i, u, _) => ( - s === TS.SEM_PRE ? _.push(['slash', MS.charsToString(o, i, u)]) : TS.SEM_POST, TS.SEM_OK + s === TS.SEM_PRE ? _.push(['slash', MS.charsToString(o, i, u)]) : TS.SEM_POST, + TS.SEM_OK ), path_template = (s, o, i, u, _) => { if (s === TS.SEM_PRE) { @@ -54146,14 +54209,16 @@ return TS.SEM_OK; }, callbacks_path = (s, o, i, u, _) => ( - s === TS.SEM_PRE ? _.push(['path', MS.charsToString(o, i, u)]) : TS.SEM_POST, TS.SEM_OK + s === TS.SEM_PRE ? _.push(['path', MS.charsToString(o, i, u)]) : TS.SEM_POST, + TS.SEM_OK ), path_literal = (s, o, i, u, _) => ( s === TS.SEM_PRE ? _.push(['path-literal', MS.charsToString(o, i, u)]) : TS.SEM_POST, TS.SEM_OK ), callbacks_query = (s, o, i, u, _) => ( - s === TS.SEM_PRE ? _.push(['query', MS.charsToString(o, i, u)]) : TS.SEM_POST, TS.SEM_OK + s === TS.SEM_PRE ? _.push(['query', MS.charsToString(o, i, u)]) : TS.SEM_POST, + TS.SEM_OK ), query_marker = (s, o, i, u, _) => ( s === TS.SEM_PRE ? _.push(['query-marker', MS.charsToString(o, i, u)]) : TS.SEM_POST, @@ -54180,7 +54245,7 @@ TS.SEM_OK ), DS = new (function path_templating_grammar() { - (this.grammarObject = 'grammarObject'), + ((this.grammarObject = 'grammarObject'), (this.rules = []), (this.rules[0] = { name: 'path-template', @@ -54412,11 +54477,11 @@ (s += 'HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"\n'), '; OpenAPI Path Templating ABNF syntax\npath-template = path [ query-marker query ] [ fragment-marker fragment ]\npath = slash *( path-segment slash ) [ path-segment ]\npath-segment = 1*( path-literal / template-expression )\nquery = *( query-literal )\nquery-literal = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" / "/" / "?" / "&" / "=" )\nquery-marker = "?"\nfragment = *( fragment-literal )\nfragment-literal = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" / "/" / "?" )\nfragment-marker = "#"\nslash = "/"\npath-literal = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" )\ntemplate-expression = "{" template-expression-param-name "}"\ntemplate-expression-param-name = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" )\n\n; Characters definitions (from RFC 3986)\nunreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"\npct-encoded = "%" HEXDIG HEXDIG\nsub-delims = "!" / "$" / "&" / "\'" / "(" / ")"\n / "*" / "+" / "," / ";" / "="\nALPHA = %x41-5A / %x61-7A ; A-Z / a-z\nDIGIT = %x30-39 ; 0-9\nHEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"\n' ); - }); + })); })(), openapi_path_templating_es_parse = (s) => { const o = new IS(); - (o.ast = new PS()), + ((o.ast = new PS()), (o.ast.callbacks['path-template'] = path_template), (o.ast.callbacks.path = callbacks_path), (o.ast.callbacks.query = callbacks_query), @@ -54426,7 +54491,7 @@ (o.ast.callbacks.slash = callbacks_slash), (o.ast.callbacks['path-literal'] = path_literal), (o.ast.callbacks['template-expression'] = template_expression), - (o.ast.callbacks['template-expression-param-name'] = template_expression_param_name); + (o.ast.callbacks['template-expression-param-name'] = template_expression_param_name)); return { result: o.parse(DS, 'path-template', s), ast: o.ast }; }, encodePathComponent = (s) => @@ -54468,15 +54533,15 @@ void 0 !== o && (s.body = o); }, header: function headerBuilder({ req: s, parameter: o, value: i }) { - (s.headers = s.headers || {}), void 0 !== i && (s.headers[o.name] = i); + ((s.headers = s.headers || {}), void 0 !== i && (s.headers[o.name] = i)); }, query: function queryBuilder({ req: s, value: o, parameter: i }) { - (s.query = s.query || {}), !1 === o && 'boolean' === i.type && (o = 'false'); + ((s.query = s.query || {}), !1 === o && 'boolean' === i.type && (o = 'false')); 0 === o && ['number', 'integer'].indexOf(i.type) > -1 && (o = '0'); if (o) s.query[i.name] = { collectionFormat: i.collectionFormat, value: o }; else if (i.allowEmptyValue && void 0 !== o) { const o = i.name; - (s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0); + ((s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0)); } }, path: function pathBuilder({ req: s, value: o, parameter: i, baseURL: u }) { @@ -54490,12 +54555,12 @@ !1 === o && 'boolean' === i.type && (o = 'false'); 0 === o && ['number', 'integer'].indexOf(i.type) > -1 && (o = '0'); if (o) - (s.form = s.form || {}), - (s.form[i.name] = { collectionFormat: i.collectionFormat, value: o }); + ((s.form = s.form || {}), + (s.form[i.name] = { collectionFormat: i.collectionFormat, value: o })); else if (i.allowEmptyValue && void 0 !== o) { s.form = s.form || {}; const o = i.name; - (s.form[o] = s.form[o] || {}), (s.form[o].allowEmptyValue = !0); + ((s.form[o] = s.form[o] || {}), (s.form[o].allowEmptyValue = !0)); } } }; @@ -54549,7 +54614,7 @@ if (u) s.query[i.name] = u; else if (i.allowEmptyValue) { const o = i.name; - (s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0); + ((s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0)); } } else if ((!1 === o && (o = 'false'), 0 === o && (o = '0'), o)) { const { style: u, explode: _, allowReserved: w } = i; @@ -54559,7 +54624,7 @@ }; } else if (i.allowEmptyValue && void 0 !== o) { const o = i.name; - (s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0); + ((s.query[o] = s.query[o] || {}), (s.query[o].allowEmptyValue = !0)); } } const FS = ['accept', 'authorization', 'content-type']; @@ -54649,9 +54714,9 @@ { type: _ } = i; if (o) if ('apiKey' === _) - 'query' === i.in && (w.query[i.name] = u), + ('query' === i.in && (w.query[i.name] = u), 'header' === i.in && (w.headers[i.name] = u), - 'cookie' === i.in && (w.cookies[i.name] = u); + 'cookie' === i.in && (w.cookies[i.name] = u)); else if ('http' === _) { if (/^basic$/i.test(i.scheme)) { const s = u.username || '', @@ -54664,8 +54729,8 @@ const s = o.token || {}, u = s[i['x-tokenName'] || 'access_token']; let _ = s.token_type; - (_ && 'bearer' !== _.toLowerCase()) || (_ = 'Bearer'), - (w.headers.Authorization = `${_} ${u}`); + ((_ && 'bearer' !== _.toLowerCase()) || (_ = 'Bearer'), + (w.headers.Authorization = `${_} ${u}`)); } }); }), @@ -54703,7 +54768,7 @@ void 0 !== $ ? $ : {}; - (o.form = {}), + ((o.form = {}), Object.keys(u).forEach((i) => { let _; try { @@ -54712,7 +54777,7 @@ _ = u[i]; } o.form[i] = { value: _, encoding: s[i] || {} }; - }); + })); } else if ('string' == typeof u) { var U, z; const s = @@ -54780,14 +54845,14 @@ if (o) if ('apiKey' === C) { const s = 'query' === x.in ? 'query' : 'headers'; - (_[s] = _[s] || {}), (_[s][x.name] = u); + ((_[s] = _[s] || {}), (_[s][x.name] = u)); } else if ('basic' === C) if (u.header) _.headers.authorization = u.header; else { const s = u.username || '', o = u.password || ''; - (u.base64 = VS(`${s}:${o}`)), - (_.headers.authorization = `Basic ${u.base64}`); + ((u.base64 = VS(`${s}:${o}`)), + (_.headers.authorization = `Basic ${u.base64}`)); } else 'oauth2' === C && @@ -54904,10 +54969,10 @@ headers: {}, cookies: {} }; - U && (ae.signal = U), + (U && (ae.signal = U), x && (ae.requestInterceptor = x), C && (ae.responseInterceptor = C), - L && (ae.userFetch = L); + L && (ae.userFetch = L)); const le = (function getOperationRaw(s, o) { return s && s.paths ? (function findOperation(s, o) { @@ -54975,14 +55040,14 @@ ? void 0 : j.servers, z = null == s ? void 0 : s.servers; - (B = isNonEmptyServerList(V) + ((B = isNonEmptyServerList(V) ? V : isNonEmptyServerList(U) ? U : isNonEmptyServerList(z) ? z : [Rc]), - u && ((L = B.find((s) => s.url === u)), L && ($ = u)); + u && ((L = B.find((s) => s.url === u)), L && ($ = u))); $ || (([L] = B), ($ = L.url)); if (openapi_server_url_templating_es_test($, { strict: !0 })) { const s = Object.entries({ ...L.variables }).reduce( @@ -55029,14 +55094,14 @@ (ae.url += ee), !u) ) - return delete ae.cookies, ae; - (ae.url += de), (ae.method = `${pe}`.toUpperCase()), (Y = Y || {}); + return (delete ae.cookies, ae); + ((ae.url += de), (ae.method = `${pe}`.toUpperCase()), (Y = Y || {})); const ye = i.paths[de] || {}; _ && (ae.headers.accept = _); const be = ((s) => { const o = {}; s.forEach((s) => { - o[s.in] || (o[s.in] = {}), (o[s.in][s.name] = s); + (o[s.in] || (o[s.in] = {}), (o[s.in][s.name] = s)); }); const i = []; return ( @@ -55087,7 +55152,7 @@ }, ''); ae.headers.Cookie = s; } - return ae.cookies && delete ae.cookies, serializeRequest(ae); + return (ae.cookies && delete ae.cookies, serializeRequest(ae)); } const stripNonAlpha = (s) => (s ? s.replace(/\W/g, '') : null); const isNonEmptyServerList = (s) => Array.isArray(s) && s.length > 0; @@ -55187,7 +55252,7 @@ if (!HS.createContext) return {}; const s = GS[JS] ?? (GS[JS] = new Map()); let o = s.get(HS.createContext); - return o || ((o = HS.createContext(null)), s.set(HS.createContext, o)), o; + return (o || ((o = HS.createContext(null)), s.set(HS.createContext, o)), o); } var YS = getContext(), notInitialized = () => { @@ -55263,7 +55328,12 @@ (j = U), z && Y ? (function handleNewPropsAndNewState() { - return (L = s(C, j)), o.dependsOnOwnProps && (B = o(u, j)), ($ = i(L, B, j)), $; + return ( + (L = s(C, j)), + o.dependsOnOwnProps && (B = o(u, j)), + ($ = i(L, B, j)), + $ + ); })() : z ? (function handleNewProps() { @@ -55278,7 +55348,7 @@ ? (function handleNewState() { const o = s(C, j), u = !x(o, L); - return (L = o), u && ($ = i(L, B, j)), $; + return ((L = o), u && ($ = i(L, B, j)), $); })() : $ ); @@ -55288,7 +55358,13 @@ ? handleSubsequentCalls(_, w) : (function handleFirstCall(_, w) { return ( - (C = _), (j = w), (L = s(C, j)), (B = o(u, j)), ($ = i(L, B, j)), (V = !0), $ + (C = _), + (j = w), + (L = s(C, j)), + (B = o(u, j)), + ($ = i(L, B, j)), + (V = !0), + $ ); })(_, w); }; @@ -55299,7 +55375,7 @@ function constantSelector() { return i; } - return (constantSelector.dependsOnOwnProps = !1), constantSelector; + return ((constantSelector.dependsOnOwnProps = !1), constantSelector); }; } function getDependsOnOwnProps(s) { @@ -55313,7 +55389,7 @@ return ( (u.dependsOnOwnProps = !0), (u.mapToProps = function detectFactoryAndVerify(o, i) { - (u.mapToProps = s), (u.dependsOnOwnProps = getDependsOnOwnProps(s)); + ((u.mapToProps = s), (u.dependsOnOwnProps = getDependsOnOwnProps(s))); let _ = u(o, i); return ( 'function' == typeof _ && @@ -55350,7 +55426,7 @@ x.onStateChange && x.onStateChange(); } function trySubscribe() { - _++, + (_++, i || ((i = o ? o.addNestedSub(handleChangeWrapper) : s.subscribe(handleChangeWrapper)), (u = (function createListenerCollection() { @@ -55358,18 +55434,18 @@ o = null; return { clear() { - (s = null), (o = null); + ((s = null), (o = null)); }, notify() { defaultNoopBatch(() => { let o = s; - for (; o; ) o.callback(), (o = o.next); + for (; o; ) (o.callback(), (o = o.next)); }); }, get() { const o = []; let i = s; - for (; i; ) o.push(i), (i = i.next); + for (; i; ) (o.push(i), (i = i.next)); return o; }, subscribe(i) { @@ -55387,10 +55463,10 @@ ); } }; - })())); + })()))); } function tryUnsubscribe() { - _--, i && 0 === _ && (i(), (i = void 0), u.clear(), (u = hx)); + (_--, i && 0 === _ && (i(), (i = void 0), u.clear(), (u = hx))); } const x = { addNestedSub: function addNestedSub(s) { @@ -55510,7 +55586,7 @@ var Cx = notInitialized, Ox = [null, null]; function captureWrapperProps(s, o, i, u, _, w) { - (s.current = u), (i.current = !1), _.current && ((_.current = null), w()); + ((s.current = u), (i.current = !1), _.current && ((_.current = null), w())); } function strictEqual(s, o) { return s === o; @@ -55567,7 +55643,7 @@ w = !1; return function mergePropsProxy(o, i, x) { const C = s(o, i, x); - return w ? u(C, _) || (_ = C) : ((w = !0), (_ = C)), _; + return (w ? u(C, _) || (_ = C) : ((w = !0), (_ = C)), _); }; }; })(s) @@ -55652,12 +55728,12 @@ try { i = u(s, _.current); } catch (s) { - (U = s), (V = s); + ((U = s), (V = s)); } - U || (V = null), + (U || (V = null), i === w.current ? x.current || L() - : ((w.current = i), (j.current = i), (x.current = !0), B()); + : ((w.current = i), (j.current = i), (x.current = !0), B())); }; return ( (i.onStateChange = checkForUpdates), @@ -55680,13 +55756,13 @@ be = Cx(ye, fe, V ? () => U(V(), w) : fe); } catch (s) { throw ( - (de.current && + de.current && (s.message += `\nThe error may be correlated with this previous error:\n${de.current.stack}\n\n`), - s) + s ); } mx(() => { - (de.current = void 0), (le.current = void 0), (ie.current = be); + ((de.current = void 0), (le.current = void 0), (ie.current = be)); }); const _e = HS.useMemo(() => HS.createElement(s, { ...be, ref: _ }), [_, s, be]); return HS.useMemo( @@ -55699,7 +55775,7 @@ const o = HS.forwardRef(function forwardConnectRef(s, o) { return HS.createElement(L, { ...s, reactReduxForwardedRef: o }); }); - return (o.displayName = i), (o.WrappedComponent = s), hoistNonReactStatics(o, s); + return ((o.displayName = i), (o.WrappedComponent = s), hoistNonReactStatics(o, s)); } return hoistNonReactStatics(L, s); }; @@ -55730,7 +55806,7 @@ o.trySubscribe(), C !== s.getState() && o.notifyNestedSubs(), () => { - o.tryUnsubscribe(), (o.onStateChange = void 0); + (o.tryUnsubscribe(), (o.onStateChange = void 0)); } ); }, [x, C]); @@ -55738,10 +55814,10 @@ return HS.createElement(j.Provider, { value: x }, i); }; var Ix; - (Ix = KS.useSyncExternalStoreWithSelector), + ((Ix = KS.useSyncExternalStoreWithSelector), ((s) => { Cx = s; - })(Pe.useSyncExternalStore); + })(Pe.useSyncExternalStore)); var Px = __webpack_require__(83488), Mx = __webpack_require__.n(Px); const withSystem = (s) => (o) => { @@ -55751,7 +55827,7 @@ return Pe.createElement(o, Rn()({}, s(), this.props, this.context)); } } - return (WithSystem.displayName = `WithSystem(${i.getDisplayName(o)})`), WithSystem; + return ((WithSystem.displayName = `WithSystem(${i.getDisplayName(o)})`), WithSystem); }, withRoot = (s, o) => (i) => { const { fn: u } = s(); @@ -55764,7 +55840,7 @@ ); } } - return (WithRoot.displayName = `WithRoot(${u.getDisplayName(i)})`), WithRoot; + return ((WithRoot.displayName = `WithRoot(${u.getDisplayName(i)})`), WithRoot); }, withConnect = (s, o, i) => compose( @@ -55787,7 +55863,7 @@ w = i(o, 'root'); class WithMappedContainer extends Pe.Component { constructor(o, i) { - super(o, i), handleProps(s, u, o, {}); + (super(o, i), handleProps(s, u, o, {})); } UNSAFE_componentWillReceiveProps(o) { handleProps(s, u, o, this.props); @@ -55899,11 +55975,11 @@ })() ) ); - _.updateLoadingStatus('success'), + (_.updateLoadingStatus('success'), _.updateSpec(o.text), - u.url() !== s && _.updateUrl(s); + u.url() !== s && _.updateUrl(s)); } - (s = s || u.url()), + ((s = s || u.url()), _.updateLoadingStatus('loading'), i.clear({ source: 'fetch' }), x({ @@ -55913,7 +55989,7 @@ responseInterceptor: C.responseInterceptor || ((s) => s), credentials: 'same-origin', headers: { Accept: 'application/json,*/*' } - }).then(next, next); + }).then(next, next)); }, updateLoadingStatus: (s) => { let o = [null, 'loading', 'failed', 'success', 'failedConfig']; @@ -56043,11 +56119,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -56276,11 +56352,11 @@ var i = Object.keys(s); if (Object.getOwnPropertySymbols) { var u = Object.getOwnPropertySymbols(s); - o && + (o && (u = u.filter(function (o) { return Object.getOwnPropertyDescriptor(s, o).enumerable; })), - i.push.apply(i, u); + i.push.apply(i, u)); } return i; } @@ -56453,7 +56529,7 @@ })(o); if (i) { var _ = o.split('\n'); - _.forEach(function (o, i) { + (_.forEach(function (o, i) { var x = u && $.length + w, C = { type: 'text', value: ''.concat(o, '\n') }; if (0 === i) { @@ -56482,12 +56558,11 @@ $.push(ee); } }), - (V = U); + (V = U)); } U++; }; U < B.length; - ) z(); if (V !== B.length - 1) { @@ -56576,8 +56651,8 @@ if (Object.getOwnPropertySymbols) { var w = Object.getOwnPropertySymbols(s); for (u = 0; u < w.length; u++) - (i = w[u]), - o.includes(i) || ({}.propertyIsEnumerable.call(s, i) && (_[i] = s[i])); + ((i = w[u]), + o.includes(i) || ({}.propertyIsEnumerable.call(s, i) && (_[i] = s[i]))); } return _; })(i, Nx); @@ -56607,7 +56682,7 @@ !$e) ) return Pe.createElement(Se, Xe, We, Pe.createElement(Te, B, qe)); - ((void 0 === pe && _e) || fe) && (pe = !0), (_e = _e || defaultRenderer); + (((void 0 === pe && _e) || fe) && (pe = !0), (_e = _e || defaultRenderer)); var Qe = [{ type: 'text', value: qe }], et = (function getCodeTree(s) { var o = s.astGenerator, @@ -56665,14 +56740,14 @@ var Xx = __webpack_require__(26571); const Zx = __webpack_require__.n(Xx)(), after_load = () => { - Bx.registerLanguage('json', Vx), + (Bx.registerLanguage('json', Vx), Bx.registerLanguage('js', qx), Bx.registerLanguage('xml', zx), Bx.registerLanguage('yaml', Jx), Bx.registerLanguage('http', Yx), Bx.registerLanguage('bash', Kx), Bx.registerLanguage('powershell', Zx), - Bx.registerLanguage('javascript', qx); + Bx.registerLanguage('javascript', qx)); }, Qx = { hljs: { @@ -57124,13 +57199,13 @@ GIT_DIRTY: !0, BUILD_TIME: 'Thu, 07 Nov 2024 14:01:17 GMT' }; - (at.versions = at.versions || {}), + ((at.versions = at.versions || {}), (at.versions.swaggerUI = { version: i, gitRevision: o, gitDirty: s, buildTimestamp: u - }); + })); }, versions = () => ({ afterLoad: versions_after_load }); var ok = __webpack_require__(47248), @@ -57182,7 +57257,7 @@ return { hasError: !0, error: s }; } constructor(...s) { - super(...s), (this.state = { hasError: !1, error: null }); + (super(...s), (this.state = { hasError: !1, error: null })); } componentDidCatch(s, o) { this.props.fn.componentDidCatch(s, o); @@ -57370,7 +57445,7 @@ } class Auths extends Pe.Component { constructor(s, o) { - super(s, o), (this.state = {}); + (super(s, o), (this.state = {})); } onAuthChange = (s) => { let { name: o } = s; @@ -57385,7 +57460,7 @@ s.preventDefault(); let { authActions: o, definitions: i } = this.props, u = i.map((s, o) => o).toArray(); - this.setState(u.reduce((s, o) => ((s[o] = ''), s), {})), o.logoutWithPersistOption(u); + (this.setState(u.reduce((s, o) => ((s[o] = ''), s), {})), o.logoutWithPersistOption(u)); }; close = (s) => { s.preventDefault(); @@ -57553,7 +57628,7 @@ let { onChange: o } = this.props, i = s.target.value, u = Object.assign({}, this.state, { value: i }); - this.setState(u), o(u); + (this.setState(u), o(u)); }; render() { let { schema: s, getComponent: o, errSelectors: i, name: u } = this.props; @@ -57623,7 +57698,7 @@ let { onChange: o } = this.props, { value: i, name: u } = s.target, _ = this.state.value; - (_[u] = i), this.setState({ value: _ }), o(this.state); + ((_[u] = i), this.setState({ value: _ }), o(this.state)); }; render() { let { schema: s, getComponent: o, name: i, errSelectors: u } = this.props; @@ -57872,12 +57947,12 @@ _(stringifyUnlessList(C)), this._setStateForCurrentNamespace({ isModifiedValueSelected: !0 }) ); - 'function' == typeof u && u(s, { isSyntheticChange: o }, ...i), + ('function' == typeof u && u(s, { isSyntheticChange: o }, ...i), this._setStateForCurrentNamespace({ lastDownstreamValue: j, isModifiedValueSelected: (o && x) || (!!w && w !== j) }), - o || ('function' == typeof _ && _(stringifyUnlessList(j))); + o || ('function' == typeof _ && _(stringifyUnlessList(j)))); }; UNSAFE_componentWillReceiveProps(s) { const { currentUserInputValue: o, examples: i, onSelect: u, userHasEditedBody: _ } = s, @@ -57887,8 +57962,8 @@ j = i.filter((s) => s.get('value') === o || stringify(s.get('value')) === o); if (j.size) { let o; - (o = j.has(s.currentKey) ? s.currentKey : j.keySeq().first()), - u(o, { isSyntheticChange: !0 }); + ((o = j.has(s.currentKey) ? s.currentKey : j.keySeq().first()), + u(o, { isSyntheticChange: !0 })); } else o !== this.props.currentUserInputValue && o !== w && @@ -57979,9 +58054,9 @@ i = (function createCodeChallenge(s) { return b64toB64UrlEncoded(kt()('sha256').update(s).digest('base64')); })(o); - $.push('code_challenge=' + i), + ($.push('code_challenge=' + i), $.push('code_challenge_method=S256'), - (s.codeVerifier = o); + (s.codeVerifier = o)); } let { additionalQueryStringParams: Y } = _; for (let s in Y) void 0 !== Y[s] && $.push([s, Y[s]].map(encodeURIComponent).join('=')); @@ -57990,7 +58065,7 @@ ee = w ? Mt()(sanitizeUrl(Z), w, !0).toString() : sanitizeUrl(Z); let ie, ae = [ee, $.join('&')].join(-1 === Z.indexOf('?') ? '?' : '&'); - (ie = + ((ie = 'implicit' === B ? o.preAuthorizeImplicit : _.useBasicAuthenticationWithAccessCodeGrant @@ -58002,7 +58077,7 @@ redirectUrl: V, callback: ie, errCb: i.newAuthErr - }); + })); } class Oauth2 extends Pe.Component { constructor(s, o) { @@ -58015,7 +58090,7 @@ B = (x && x.get('clientSecret')) || C.clientSecret || '', $ = (x && x.get('passwordType')) || 'basic', V = (x && x.get('scopes')) || C.scopes || []; - 'string' == typeof V && (V = V.split(C.scopeSeparator || ' ')), + ('string' == typeof V && (V = V.split(C.scopeSeparator || ' ')), (this.state = { appName: C.appName, name: i, @@ -58026,7 +58101,7 @@ username: j, password: '', passwordType: $ - }); + })); } close = (s) => { s.preventDefault(); @@ -58043,7 +58118,7 @@ } = this.props, w = i(), x = u.getConfigs(); - o.clear({ authId: name, type: 'auth', source: 'auth' }), + (o.clear({ authId: name, type: 'auth', source: 'auth' }), oauth2_authorize_authorize({ auth: this.state, currentServer: _.serverEffectiveValue(_.selectedServer()), @@ -58051,7 +58126,7 @@ errActions: o, configs: w, authConfigs: x - }); + })); }; onScopeChange = (s) => { let { target: o } = s, @@ -58089,7 +58164,7 @@ logout = (s) => { s.preventDefault(); let { authActions: o, errActions: i, name: u } = this.props; - i.clear({ authId: u, type: 'auth', source: 'auth' }), o.logoutWithPersistOption([u]); + (i.clear({ authId: u, type: 'auth', source: 'auth' }), o.logoutWithPersistOption([u])); }; render() { let { @@ -58360,7 +58435,7 @@ class Clear extends Pe.Component { onClick = () => { let { specActions: s, path: o, method: i } = this.props; - s.clearResponse(o, i), s.clearRequest(o, i); + (s.clearResponse(o, i), s.clearRequest(o, i)); }; render() { return Pe.createElement( @@ -58561,28 +58636,28 @@ } class ValidatorImage extends Pe.Component { constructor(s) { - super(s), (this.state = { loaded: !1, error: !1 }); + (super(s), (this.state = { loaded: !1, error: !1 })); } componentDidMount() { const s = new Image(); - (s.onload = () => { + ((s.onload = () => { this.setState({ loaded: !0 }); }), (s.onerror = () => { this.setState({ error: !0 }); }), - (s.src = this.props.src); + (s.src = this.props.src)); } UNSAFE_componentWillReceiveProps(s) { if (s.src !== this.props.src) { const o = new Image(); - (o.onload = () => { + ((o.onload = () => { this.setState({ loaded: !0 }); }), (o.onerror = () => { this.setState({ error: !0 }); }), - (o.src = s.src); + (o.src = s.src)); } } render() { @@ -59091,13 +59166,13 @@ UNSAFE_componentWillReceiveProps(s) { const { response: o, isShown: i } = s, u = this.getResolvedSubtree(); - o !== this.props.response && this.setState({ executeInProgress: !1 }), - i && void 0 === u && this.requestResolvedSubtree(); + (o !== this.props.response && this.setState({ executeInProgress: !1 }), + i && void 0 === u && this.requestResolvedSubtree()); } toggleShown = () => { let { layoutActions: s, tag: o, operationId: i, isShown: u } = this.props; const _ = this.getResolvedSubtree(); - u || void 0 !== _ || this.requestResolvedSubtree(), s.show(['operations', o, i], !u); + (u || void 0 !== _ || this.requestResolvedSubtree(), s.show(['operations', o, i], !u)); }; onCancelClick = () => { this.setState({ tryItOutEnabled: !this.state.tryItOutEnabled }); @@ -59555,12 +59630,12 @@ } class response_Response extends Pe.Component { constructor(s, o) { - super(s, o), (this.state = { responseContentType: '' }); + (super(s, o), (this.state = { responseContentType: '' })); } static defaultProps = { response: (0, qe.fromJS)({}), onContentTypeChange: () => {} }; _onContentTypeChange = (s) => { const { onContentTypeChange: o, controlsAcceptHeader: i } = this.props; - this.setState({ responseContentType: s }), o({ value: s, controlsAcceptHeader: i }); + (this.setState({ responseContentType: s }), o({ value: s, controlsAcceptHeader: i })); }; getTargetExamplesKey = () => { const { response: s, contentType: o, activeExamplesKey: i } = this.props, @@ -59609,9 +59684,9 @@ $e = Re.get('examples', null); if (Y) { const s = Re.get('schema'); - (Se = s ? U(s.toJS()) : null), - (xe = s ? (0, qe.List)(['content', this.state.responseContentType, 'schema']) : w); - } else (Se = u.get('schema')), (xe = u.has('schema') ? w.push('schema') : w); + ((Se = s ? U(s.toJS()) : null), + (xe = s ? (0, qe.List)(['content', this.state.responseContentType, 'schema']) : w)); + } else ((Se = u.get('schema')), (xe = u.has('schema') ? w.push('schema') : w)); let ze, We, He = !1, @@ -59620,12 +59695,12 @@ if (((We = Re.get('schema')?.toJS()), qe.Map.isMap($e) && !$e.isEmpty())) { const s = this.getTargetExamplesKey(), getMediaTypeExample = (s) => s.get('value'); - (ze = getMediaTypeExample($e.get(s, (0, qe.Map)({})))), + ((ze = getMediaTypeExample($e.get(s, (0, qe.Map)({})))), void 0 === ze && (ze = getMediaTypeExample($e.values().next().value)), - (He = !0); + (He = !0)); } else void 0 !== Re.get('example') && ((ze = Re.get('example')), (He = !0)); else { - (We = Se), (Ye = { ...Ye, includeWriteOnly: !0 }); + ((We = Se), (Ye = { ...Ye, includeWriteOnly: !0 })); const s = u.getIn(['examples', Te]); s && ((ze = s), (He = !0)); } @@ -59765,10 +59840,10 @@ if (s !== o) if (o && o instanceof Blob) { var i = new FileReader(); - (i.onload = () => { + ((i.onload = () => { this.setState({ parsedContent: i.result }); }), - i.readAsText(o); + i.readAsText(o)); } else this.setState({ parsedContent: o.toString() }); }; componentDidMount() { @@ -59930,7 +60005,7 @@ } class Parameters extends Pe.Component { constructor(s) { - super(s), (this.state = { callbackVisible: !1, parametersVisible: !0 }); + (super(s), (this.state = { callbackVisible: !1, parametersVisible: !0 })); } static defaultProps = { onTryoutClick: Function.prototype, @@ -59964,13 +60039,13 @@ let { specActions: i, oas3Selectors: u, oas3Actions: _ } = this.props; const w = u.hasUserEditedBody(...o), x = u.shouldRetainRequestBodyValue(...o); - _.setRequestContentType({ value: s, pathMethod: o }), + (_.setRequestContentType({ value: s, pathMethod: o }), _.initRequestBodyValidateError({ pathMethod: o }), w || (x || _.setRequestBodyValue({ value: void 0, pathMethod: o }), i.clearResponse(...o), i.clearRequest(...o), - i.clearValidateParams(o)); + i.clearValidateParams(o))); }; render() { let { @@ -60002,7 +60077,7 @@ fe = Object.values( i.reduce((s, o) => { const i = o.get('in'); - return (s[i] ??= []), s[i].push(o), s; + return ((s[i] ??= []), s[i].push(o), s); }, {}) ).reduce((s, o) => s.concat(o), []); return Pe.createElement( @@ -60240,7 +60315,7 @@ } class ParameterRow extends Pe.Component { constructor(s, o) { - super(s, o), this.setDefaultValue(); + (super(s, o), this.setDefaultValue()); } UNSAFE_componentWillReceiveProps(s) { let o, @@ -60253,7 +60328,7 @@ } else o = x ? x.get('enum') : void 0; let C, j = x ? x.get('value') : void 0; - void 0 !== j ? (C = j) : _.get('required') && o && o.size && (C = o.first()), + (void 0 !== j ? (C = j) : _.get('required') && o && o.size && (C = o.first()), void 0 !== C && C !== j && this.onChangeWrapper( @@ -60261,12 +60336,12 @@ return 'number' == typeof s ? s.toString() : s; })(C) ), - this.setDefaultValue(); + this.setDefaultValue()); } onChangeWrapper = (s, o = !1) => { let i, { onChange: u, rawParam: _ } = this.props; - return (i = '' === s || (s && 0 === s.size) ? null : s), u(_, i, o); + return ((i = '' === s || (s && 0 === s.size) ? null : s), u(_, i, o)); }; _onExampleSelect = (s) => { this.props.oas3Actions.setActiveExamplesMember({ @@ -60322,14 +60397,14 @@ ? x && x.get('default') : w.get('default'); } - void 0 === i || qe.List.isList(i) || (i = stringify(i)), + (void 0 === i || qe.List.isList(i) || (i = stringify(i)), void 0 !== i ? this.onChangeWrapper(i) : x && 'object' === x.get('type') && j && !w.get('examples') && - this.onChangeWrapper(qe.List.isList(j) ? j : stringify(j)); + this.onChangeWrapper(qe.List.isList(j) ? j : stringify(j))); } }; getParamKey() { @@ -60549,7 +60624,7 @@ class Execute extends Pe.Component { handleValidateParameters = () => { let { specSelectors: s, specActions: o, path: i, method: u } = this.props; - return o.validateParams([i, u]), s.validateBeforeExecute([i, u]); + return (o.validateParams([i, u]), s.validateBeforeExecute([i, u])); }; handleValidateRequestBody = () => { let { @@ -60589,15 +60664,15 @@ }; handleValidationResultPass = () => { let { specActions: s, operation: o, path: i, method: u } = this.props; - this.props.onExecute && this.props.onExecute(), - s.execute({ operation: o, path: i, method: u }); + (this.props.onExecute && this.props.onExecute(), + s.execute({ operation: o, path: i, method: u })); }; handleValidationResultFail = () => { let { specActions: s, path: o, method: i } = this.props; - s.clearValidateParams([o, i]), + (s.clearValidateParams([o, i]), setTimeout(() => { s.validateParams([o, i]); - }, 40); + }, 40)); }; handleValidationResult = (s) => { s ? this.handleValidationResultPass() : this.handleValidationResultFail(); @@ -60899,7 +60974,7 @@ C.push('none' + o); continue; } - C.push('block' + o), C.push('col-' + i + o); + (C.push('block' + o), C.push('col-' + i + o)); } } s && C.push('hidden'); @@ -60930,15 +61005,15 @@ static defaultProps = { multiple: !1, allowEmptyValue: !0 }; constructor(s, o) { let i; - super(s, o), + (super(s, o), (i = s.value ? s.value : s.multiple ? [''] : ''), - (this.state = { value: i }); + (this.state = { value: i })); } onChange = (s) => { let o, { onChange: i, multiple: u } = this.props, _ = [].slice.call(s.target.options); - (o = u + ((o = u ? _.filter(function (s) { return s.selected; }).map(function (s) { @@ -60946,7 +61021,7 @@ }) : s.target.value), this.setState({ value: o }), - i && i(o); + i && i(o)); }; UNSAFE_componentWillReceiveProps(s) { s.value !== this.props.value && this.setState({ value: s.value }); @@ -60999,7 +61074,7 @@ } class Overview extends Pe.Component { constructor(...s) { - super(...s), (this.setTagShown = this._setTagShown.bind(this)); + (super(...s), (this.setTagShown = this._setTagShown.bind(this))); } _setTagShown(s, o) { this.props.layoutActions.show(s, o); @@ -61064,7 +61139,7 @@ } class OperationLink extends Pe.Component { constructor(s) { - super(s), (this.onClick = this._onClick.bind(this)); + (super(s), (this.onClick = this._onClick.bind(this))); } _onClick() { let { showOpId: s, showOpIdPrefix: o, onClick: i, shown: u } = this.props; @@ -61341,7 +61416,7 @@ onChangeConsumes: eC }; constructor(s, o) { - super(s, o), (this.state = { isEditBox: !1, value: '' }); + (super(s, o), (this.state = { isEditBox: !1, value: '' })); } componentDidMount() { this.updateValues.call(this, this.props); @@ -61356,7 +61431,7 @@ x = _ ? o.get('value_xml') : o.get('value'); if (void 0 !== x) { let s = !x && w ? '{}' : x; - this.setState({ value: s }), this.onChange(s, { isXml: _, isEditBox: i }); + (this.setState({ value: s }), this.onChange(s, { isXml: _, isEditBox: i })); } else _ ? this.onChange(this.sample('xml'), { isXml: _, isEditBox: i }) @@ -61368,7 +61443,7 @@ return i.getSampleSchema(u, s, { includeWriteOnly: !0 }); }; onChange = (s, { isEditBox: o, isXml: i }) => { - this.setState({ value: s, isEditBox: o }), this._onChange(s, i); + (this.setState({ value: s, isEditBox: o }), this._onChange(s, i)); }; _onChange = (s, o) => { (this.props.onChange || eC)(s, o); @@ -61720,7 +61795,8 @@ var tC; function decodeEntity(s) { return ( - ((tC = tC || document.createElement('textarea')).innerHTML = '&' + s + ';'), tC.value + ((tC = tC || document.createElement('textarea')).innerHTML = '&' + s + ';'), + tC.value ); } var rC = Object.prototype.hasOwnProperty; @@ -61807,7 +61883,7 @@ ? nextToken(s, o + 2) : o; } - (cC.blockquote_open = function () { + ((cC.blockquote_open = function () { return '
\n'; }), (cC.blockquote_close = function (s, o) { @@ -62048,18 +62124,18 @@ }), (cC.dd_close = function () { return '\n'; - }); + })); var uC = (cC.getBreak = function getBreak(s, o) { return (o = nextToken(s, o)) < s.length && 'list_item_close' === s[o].type ? '' : '\n'; }); function Renderer() { - (this.rules = index_browser_assign({}, cC)), (this.getBreak = cC.getBreak); + ((this.rules = index_browser_assign({}, cC)), (this.getBreak = cC.getBreak)); } function Ruler() { - (this.__rules__ = []), (this.__cache__ = null); + ((this.__rules__ = []), (this.__cache__ = null)); } function StateInline(s, o, i, u, _) { - (this.src = s), + ((this.src = s), (this.env = u), (this.options = i), (this.parser = o), @@ -62073,7 +62149,7 @@ (this.isInLabel = !1), (this.linkLevel = 0), (this.linkContent = ''), - (this.labelUnmatchedScopes = 0); + (this.labelUnmatchedScopes = 0)); } function parseLinkLabel(s, o) { var i, @@ -62084,7 +62160,7 @@ C = s.pos, j = s.isInLabel; if (s.isInLabel) return -1; - if (s.labelUnmatchedScopes) return s.labelUnmatchedScopes--, -1; + if (s.labelUnmatchedScopes) return (s.labelUnmatchedScopes--, -1); for (s.pos = o + 1, s.isInLabel = !0, i = 1; s.pos < x; ) { if (91 === (_ = s.src.charCodeAt(s.pos))) i++; else if (93 === _ && 0 === --i) { @@ -62166,7 +62242,7 @@ if (34 !== w && 39 !== w && 40 !== w) return !1; for (o++, 40 === w && (w = 41); o < _; ) { if ((i = s.src.charCodeAt(o)) === w) - return (s.pos = o + 1), (s.linkContent = unescapeMd(s.src.slice(u + 1, o))), !0; + return ((s.pos = o + 1), (s.linkContent = unescapeMd(s.src.slice(u + 1, o))), !0); 92 === i && o + 1 < _ ? (o += 2) : o++; } return !1; @@ -62199,7 +62275,6 @@ ? (($ = _.linkContent), (x = _.pos)) : (($ = ''), (x = L)); x < C && 32 === _.src.charCodeAt(x); - ) x++; return x < C && 10 !== _.src.charCodeAt(x) @@ -62208,7 +62283,7 @@ void 0 === u.references[V] && (u.references[V] = { title: $, href: B }), x); } - (Renderer.prototype.renderInline = function (s, o, i) { + ((Renderer.prototype.renderInline = function (s, o, i) { for (var u = this.rules, _ = s.length, w = 0, x = ''; _--; ) x += u[s[w].type](s, w++, o, i, this); return x; @@ -62228,7 +62303,7 @@ (Ruler.prototype.__compile__ = function () { var s = this, o = ['']; - s.__rules__.forEach(function (s) { + (s.__rules__.forEach(function (s) { s.enabled && s.alt.forEach(function (s) { o.indexOf(s) < 0 && o.push(s); @@ -62236,41 +62311,41 @@ }), (s.__cache__ = {}), o.forEach(function (o) { - (s.__cache__[o] = []), + ((s.__cache__[o] = []), s.__rules__.forEach(function (i) { i.enabled && ((o && i.alt.indexOf(o) < 0) || s.__cache__[o].push(i.fn)); - }); - }); + })); + })); }), (Ruler.prototype.at = function (s, o, i) { var u = this.__find__(s), _ = i || {}; if (-1 === u) throw new Error('Parser rule not found: ' + s); - (this.__rules__[u].fn = o), + ((this.__rules__[u].fn = o), (this.__rules__[u].alt = _.alt || []), - (this.__cache__ = null); + (this.__cache__ = null)); }), (Ruler.prototype.before = function (s, o, i, u) { var _ = this.__find__(s), w = u || {}; if (-1 === _) throw new Error('Parser rule not found: ' + s); - this.__rules__.splice(_, 0, { name: o, enabled: !0, fn: i, alt: w.alt || [] }), - (this.__cache__ = null); + (this.__rules__.splice(_, 0, { name: o, enabled: !0, fn: i, alt: w.alt || [] }), + (this.__cache__ = null)); }), (Ruler.prototype.after = function (s, o, i, u) { var _ = this.__find__(s), w = u || {}; if (-1 === _) throw new Error('Parser rule not found: ' + s); - this.__rules__.splice(_ + 1, 0, { name: o, enabled: !0, fn: i, alt: w.alt || [] }), - (this.__cache__ = null); + (this.__rules__.splice(_ + 1, 0, { name: o, enabled: !0, fn: i, alt: w.alt || [] }), + (this.__cache__ = null)); }), (Ruler.prototype.push = function (s, o, i) { var u = i || {}; - this.__rules__.push({ name: s, enabled: !0, fn: o, alt: u.alt || [] }), - (this.__cache__ = null); + (this.__rules__.push({ name: s, enabled: !0, fn: o, alt: u.alt || [] }), + (this.__cache__ = null)); }), (Ruler.prototype.enable = function (s, o) { - (s = Array.isArray(s) ? s : [s]), + ((s = Array.isArray(s) ? s : [s]), o && this.__rules__.forEach(function (s) { s.enabled = !1; @@ -62280,27 +62355,27 @@ if (o < 0) throw new Error('Rules manager: invalid rule name ' + s); this.__rules__[o].enabled = !0; }, this), - (this.__cache__ = null); + (this.__cache__ = null)); }), (Ruler.prototype.disable = function (s) { - (s = Array.isArray(s) ? s : [s]).forEach(function (s) { + ((s = Array.isArray(s) ? s : [s]).forEach(function (s) { var o = this.__find__(s); if (o < 0) throw new Error('Rules manager: invalid rule name ' + s); this.__rules__[o].enabled = !1; }, this), - (this.__cache__ = null); + (this.__cache__ = null)); }), (Ruler.prototype.getRules = function (s) { - return null === this.__cache__ && this.__compile__(), this.__cache__[s] || []; + return (null === this.__cache__ && this.__compile__(), this.__cache__[s] || []); }), (StateInline.prototype.pushPending = function () { - this.tokens.push({ type: 'text', content: this.pending, level: this.pendingLevel }), - (this.pending = ''); + (this.tokens.push({ type: 'text', content: this.pending, level: this.pendingLevel }), + (this.pending = '')); }), (StateInline.prototype.push = function (s) { - this.pending && this.pushPending(), + (this.pending && this.pushPending(), this.tokens.push(s), - (this.pendingLevel = this.level); + (this.pendingLevel = this.level)); }), (StateInline.prototype.cacheSet = function (s, o) { for (var i = this.cache.length; i <= s; i++) this.cache.push(0); @@ -62308,7 +62383,7 @@ }), (StateInline.prototype.cacheGet = function (s) { return s < this.cache.length ? this.cache[s] : 0; - }); + })); var pC = ' \n()[]\'".,!?-'; function regEscape(s) { return s.replace(/([-()\[\]{}+?*.$\^|,:# j && + (B.lastIndex > j && C.push({ type: 'text', content: x.slice(j, $.index + $[1].length), @@ -62530,7 +62604,7 @@ }), C.push({ type: 'text', content: $[2], level: L }), C.push({ type: 'abbr_close', level: --L }), - (j = B.lastIndex - $[3].length); + (j = B.lastIndex - $[3].length)); C.length && (j < x.length && C.push({ type: 'text', content: x.slice(j), level: L }), (U[i].children = _ = [].concat(_.slice(0, o), C, _.slice(o + 1)))); @@ -62570,7 +62644,7 @@ for (Z = s.tokens[Y].children, ee.length = 0, o = 0; o < Z.length; o++) if ('text' === (i = Z[o]).type && !mC.test(i.text)) { for (C = Z[o].level, U = ee.length - 1; U >= 0 && !(ee[U].level <= C); U--); - (ee.length = U + 1), (w = 0), (x = (u = i.content).length); + ((ee.length = U + 1), (w = 0), (x = (u = i.content).length)); e: for (; w < x && ((gC.lastIndex = w), (_ = gC.exec(u))); ) if ( ((j = !isLetter(u, _.index - 1)), @@ -62585,7 +62659,7 @@ U-- ) if (B.single === z && ee[U].level === C) { - (B = ee[U]), + ((B = ee[U]), z ? ((Z[B.token].content = replaceAt( Z[B.token].content, @@ -62607,7 +62681,7 @@ _.index, s.options.quotes[1] ))), - (ee.length = U); + (ee.length = U)); continue e; } $ @@ -62619,7 +62693,7 @@ ] ]; function Core() { - (this.options = {}), (this.ruler = new Ruler()); + ((this.options = {}), (this.ruler = new Ruler())); for (var s = 0; s < vC.length; s++) this.ruler.push(vC[s][0], vC[s][1]); } function StateBlock(s, o, i, u, _) { @@ -62664,10 +62738,10 @@ (B = 0), (C = j + 1)); } - this.bMarks.push(x.length), + (this.bMarks.push(x.length), this.eMarks.push(x.length), this.tShift.push(0), - (this.lineMax = this.bMarks.length - 1); + (this.lineMax = this.bMarks.length - 1)); } function skipBulletListMarker(s, o) { var i, u, _; @@ -62692,7 +62766,7 @@ } return u < _ && 32 !== s.src.charCodeAt(u) ? -1 : u; } - (Core.prototype.process = function (s) { + ((Core.prototype.process = function (s) { var o, i, u; for (o = 0, i = (u = this.ruler.getRules('')).length; o < i; o++) u[o](s); }), @@ -62735,13 +62809,13 @@ this.src.slice(w, x) ); for (C = new Array(o - s), _ = 0; L < o; L++, _++) - (j = this.tShift[L]) > i && (j = i), + ((j = this.tShift[L]) > i && (j = i), j < 0 && (j = 0), (w = this.bMarks[L] + j), (x = L + 1 < o || u ? this.eMarks[L] + 1 : this.eMarks[L]), - (C[_] = this.src.slice(w, x)); + (C[_] = this.src.slice(w, x))); return C.join(''); - }); + })); var bC = {}; [ 'article', @@ -62864,7 +62938,6 @@ (B = j = s.bMarks[C] + s.tShift[C]) < ($ = s.eMarks[C]) && s.tShift[C] < s.blkIndent ); - ) if ( s.src.charCodeAt(B) === _ && @@ -62934,14 +63007,14 @@ break; } if (z) break; - C.push(s.bMarks[_]), x.push(s.tShift[_]), (s.tShift[_] = -1337); + (C.push(s.bMarks[_]), x.push(s.tShift[_]), (s.tShift[_] = -1337)); } else - 32 === s.src.charCodeAt(Y) && Y++, + (32 === s.src.charCodeAt(Y) && Y++, C.push(s.bMarks[_]), (s.bMarks[_] = Y), (w = (Y = Y < Z ? s.skipSpaces(Y) : Y) >= Z), x.push(s.tShift[_]), - (s.tShift[_] = Y - s.bMarks[_]); + (s.tShift[_] = Y - s.bMarks[_])); for ( L = s.parentType, s.parentType = 'blockquote', @@ -62954,8 +63027,8 @@ V < x.length; V++ ) - (s.bMarks[V + o] = C[V]), (s.tShift[V + o] = x[V]); - return (s.blkIndent = j), !0; + ((s.bMarks[V + o] = C[V]), (s.tShift[V + o] = x[V])); + return ((s.blkIndent = j), !0); }, ['paragraph', 'blockquote', 'list'] ], @@ -63063,7 +63136,6 @@ s.isEmpty(_) || s.tShift[_] < s.blkIndent ); - ) { for (fe = !1, pe = 0, de = ce.length; pe < de; pe++) if (ce[pe](s, _, i, !0)) { @@ -63157,7 +63229,7 @@ if (C >= j) return !1; if (35 !== (_ = s.src.charCodeAt(C)) || C >= j) return !1; for (w = 1, _ = s.src.charCodeAt(++C); 35 === _ && C < j && w <= 6; ) - w++, (_ = s.src.charCodeAt(++C)); + (w++, (_ = s.src.charCodeAt(++C))); return ( !(w > 6 || (C < j && 32 !== _)) && (u || @@ -63299,7 +63371,7 @@ C < L.length; C++ ) - s.tokens.push({ + (s.tokens.push({ type: 'th_open', align: $[C], lines: [o, o + 1], @@ -63312,7 +63384,7 @@ level: s.level, children: [] }), - s.tokens.push({ type: 'th_close', level: --s.level }); + s.tokens.push({ type: 'th_close', level: --s.level })); for ( s.tokens.push({ type: 'tr_close', level: --s.level }), s.tokens.push({ type: 'thead_close', level: --s.level }), @@ -63330,13 +63402,13 @@ C < L.length; C++ ) - s.tokens.push({ type: 'td_open', align: $[C], level: s.level++ }), + (s.tokens.push({ type: 'td_open', align: $[C], level: s.level++ }), (B = L[C].substring( 124 === L[C].charCodeAt(0) ? 1 : 0, 124 === L[C].charCodeAt(L[C].length - 1) ? L[C].length - 1 : L[C].length ).trim()), s.tokens.push({ type: 'inline', content: B, level: s.level, children: [] }), - s.tokens.push({ type: 'td_close', level: --s.level }); + s.tokens.push({ type: 'td_close', level: --s.level })); s.tokens.push({ type: 'tr_close', level: --s.level }); } return ( @@ -63358,10 +63430,10 @@ if (s.tShift[B] < s.blkIndent) return !1; if ((_ = skipMarker(s, B)) < 0) return !1; if (s.level >= s.options.maxNesting) return !1; - (L = s.tokens.length), + ((L = s.tokens.length), s.tokens.push({ type: 'dl_open', lines: (j = [o, 0]), level: s.level++ }), (x = o), - (w = B); + (w = B)); e: for (;;) { for ( ee = !0, @@ -63376,7 +63448,6 @@ }), s.tokens.push({ type: 'dt_close', level: --s.level }); ; - ) { if ( (s.tokens.push({ type: 'dd_open', lines: (C = [B, 0]), level: s.level++ }), @@ -63487,7 +63558,6 @@ x < i && ((s.line = x = s.skipEmptyLines(x)), !(x >= i)) && !(s.tShift[x] < s.blkIndent); - ) { for (u = 0; u < w && !_[u](s, x, i, !1); u++); if ( @@ -63534,7 +63604,7 @@ w = 0, x = 0; if (!s) return []; - (s = (s = s.replace(kC, ' ')).replace(xC, '\n')).indexOf('\t') >= 0 && + ((s = (s = s.replace(kC, ' ')).replace(xC, '\n')).indexOf('\t') >= 0 && (s = s.replace(SC, function (o, i) { var u; return 10 === s.charCodeAt(i) @@ -63542,7 +63612,7 @@ : ((u = ' '.slice((i - w - x) % 4)), (x = i - w + 1), u); })), (_ = new StateBlock(s, this, o, i, u)), - this.tokenize(_, _.line, _.lineMax); + this.tokenize(_, _.line, _.lineMax)); }; for (var CC = [], OC = 0; OC < 256; OC++) CC.push(0); function isAlphaNum(s) { @@ -63797,11 +63867,11 @@ } s.push({ type: 'hardbreak', level: s.level }); } else - (s.pending = s.pending.slice(0, -1)), - s.push({ type: 'softbreak', level: s.level }); + ((s.pending = s.pending.slice(0, -1)), + s.push({ type: 'softbreak', level: s.level })); else s.push({ type: 'softbreak', level: s.level }); for (_++; _ < u && 32 === s.src.charCodeAt(_); ) _++; - return (s.pos = _), !0; + return ((s.pos = _), !0); } ], [ @@ -63813,18 +63883,17 @@ if (92 !== s.src.charCodeAt(u)) return !1; if (++u < _) { if ((i = s.src.charCodeAt(u)) < 256 && 0 !== CC[i]) - return o || (s.pending += s.src[u]), (s.pos += 2), !0; + return (o || (s.pending += s.src[u]), (s.pos += 2), !0); if (10 === i) { for ( o || s.push({ type: 'hardbreak', level: s.level }), u++; u < _ && 32 === s.src.charCodeAt(u); - ) u++; - return (s.pos = u), !0; + return ((s.pos = u), !0); } } - return o || (s.pending += '\\'), s.pos++, !0; + return (o || (s.pending += '\\'), s.pos++, !0); } ], [ @@ -63856,7 +63925,7 @@ !0 ); } - return o || (s.pending += _), (s.pos += _.length), !0; + return (o || (s.pending += _), (s.pos += _.length), !0); } ], [ @@ -63883,7 +63952,7 @@ if (126 === x) return !1; if (32 === x || 10 === x) return !1; for (u = j + 2; u < C && 126 === s.src.charCodeAt(u); ) u++; - if (u > j + 3) return (s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0; + if (u > j + 3) return ((s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0); for (s.pos = j + 2, _ = 1; s.pos + 1 < C; ) { if ( 126 === s.src.charCodeAt(s.pos) && @@ -63935,7 +64004,7 @@ if (43 === x) return !1; if (32 === x || 10 === x) return !1; for (u = j + 2; u < C && 43 === s.src.charCodeAt(u); ) u++; - if (u !== j + 2) return (s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0; + if (u !== j + 2) return ((s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0); for (s.pos = j + 2, _ = 1; s.pos + 1 < C; ) { if ( 43 === s.src.charCodeAt(s.pos) && @@ -63987,7 +64056,7 @@ if (61 === x) return !1; if (32 === x || 10 === x) return !1; for (u = j + 2; u < C && 61 === s.src.charCodeAt(u); ) u++; - if (u !== j + 2) return (s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0; + if (u !== j + 2) return ((s.pos += u - j), o || (s.pending += s.src.slice(j, u)), !0); for (s.pos = j + 2, _ = 1; s.pos + 1 < C; ) { if ( 61 === s.src.charCodeAt(s.pos) && @@ -64031,7 +64100,7 @@ if (95 !== $ && 42 !== $) return !1; if (o) return !1; if (((i = (j = scanDelims(s, B)).delims), !j.can_open)) - return (s.pos += i), o || (s.pending += s.src.slice(B, s.pos)), !0; + return ((s.pos += i), o || (s.pending += s.src.slice(B, s.pos)), !0); if (s.level >= s.options.maxNesting) return !1; for (s.pos = B + i, C = [i]; s.pos < L; ) if (s.src.charCodeAt(s.pos) !== $) s.parser.skipToken(s); @@ -64043,16 +64112,16 @@ break; } if (((x -= w), 0 === C.length)) break; - (s.pos += w), (w = C.pop()); + ((s.pos += w), (w = C.pop())); } if (0 === C.length) { - (i = w), (_ = !0); + ((i = w), (_ = !0)); break; } s.pos += u; continue; } - j.can_open && C.push(u), (s.pos += u); + (j.can_open && C.push(u), (s.pos += u)); } return _ ? ((s.posMax = s.pos), @@ -64165,7 +64234,7 @@ C++ ); else x = ''; - if (C >= V || 41 !== s.src.charCodeAt(C)) return (s.pos = $), !1; + if (C >= V || 41 !== s.src.charCodeAt(C)) return ((s.pos = $), !1); C++; } else { if (s.linkLevel > 0) return !1; @@ -64178,8 +64247,8 @@ _ || (void 0 === _ && (C = u + 1), (_ = s.src.slice(i, u))), !(j = s.env.references[normalizeReference(_)])) ) - return (s.pos = $), !1; - (w = j.href), (x = j.title); + return ((s.pos = $), !1); + ((w = j.href), (x = j.title)); } return ( o || @@ -64370,9 +64439,9 @@ ); } else if ((u = s.src.slice(_).match(BC))) { var x = decodeEntity(u[1]); - if (u[1] !== x) return o || (s.pending += x), (s.pos += u[0].length), !0; + if (u[1] !== x) return (o || (s.pending += x), (s.pos += u[0].length), !0); } - return o || (s.pending += '&'), s.pos++, !0; + return (o || (s.pending += '&'), s.pos++, !0); } ] ]; @@ -64388,7 +64457,7 @@ -1 === ['vbscript', 'javascript', 'file', 'data'].indexOf(o.split(':')[0]) ); } - (ParserInline.prototype.skipToken = function (s) { + ((ParserInline.prototype.skipToken = function (s) { var o, i, u = this.ruler.getRules(''), @@ -64397,7 +64466,7 @@ if ((i = s.cacheGet(w)) > 0) s.pos = i; else { for (o = 0; o < _; o++) if (u[o](s, !0)) return void s.cacheSet(w, s.pos); - s.pos++, s.cacheSet(w, s.pos); + (s.pos++, s.cacheSet(w, s.pos)); } }), (ParserInline.prototype.tokenize = function (s) { @@ -64412,7 +64481,7 @@ (ParserInline.prototype.parse = function (s, o, i, u) { var _ = new StateInline(s, this, o, i, u); this.tokenize(_); - }); + })); var qC = { default: { options: { @@ -64529,7 +64598,7 @@ } }; function StateCore(s, o, i) { - (this.src = o), + ((this.src = o), (this.env = i), (this.options = s.options), (this.tokens = []), @@ -64537,10 +64606,10 @@ (this.inline = s.inline), (this.block = s.block), (this.renderer = s.renderer), - (this.typographer = s.typographer); + (this.typographer = s.typographer)); } function Remarkable(s, o) { - 'string' != typeof s && ((o = s), (s = 'default')), + ('string' != typeof s && ((o = s), (s = 'default')), o && null != o.linkify && console.warn( @@ -64553,37 +64622,37 @@ (this.ruler = new Ruler()), (this.options = {}), this.configure(qC[s]), - this.set(o || {}); + this.set(o || {})); } - (Remarkable.prototype.set = function (s) { + ((Remarkable.prototype.set = function (s) { index_browser_assign(this.options, s); }), (Remarkable.prototype.configure = function (s) { var o = this; if (!s) throw new Error('Wrong `remarkable` preset, check name/content'); - s.options && o.set(s.options), + (s.options && o.set(s.options), s.components && Object.keys(s.components).forEach(function (i) { s.components[i].rules && o[i].ruler.enable(s.components[i].rules, !0); - }); + })); }), (Remarkable.prototype.use = function (s, o) { - return s(this, o), this; + return (s(this, o), this); }), (Remarkable.prototype.parse = function (s, o) { var i = new StateCore(this, s, o); - return this.core.process(i), i.tokens; + return (this.core.process(i), i.tokens); }), (Remarkable.prototype.render = function (s, o) { - return (o = o || {}), this.renderer.render(this.parse(s, o), this.options, o); + return ((o = o || {}), this.renderer.render(this.parse(s, o), this.options, o)); }), (Remarkable.prototype.parseInline = function (s, o) { var i = new StateCore(this, s, o); - return (i.inlineMode = !0), this.core.process(i), i.tokens; + return ((i.inlineMode = !0), this.core.process(i), i.tokens); }), (Remarkable.prototype.renderInline = function (s, o) { - return (o = o || {}), this.renderer.render(this.parseInline(s, o), this.options, o); - }); + return ((o = o || {}), this.renderer.render(this.parseInline(s, o), this.options, o)); + })); function indexOf(s, o) { if (Array.prototype.indexOf) return s.indexOf(o); for (var i = 0, u = s.length; i < u; i++) if (s[i] === o) return i; @@ -64597,30 +64666,30 @@ } var $C = (function () { function HtmlTag(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.tagName = ''), (this.attrs = {}), (this.innerHTML = ''), (this.whitespaceRegex = /\s+/), (this.tagName = s.tagName || ''), (this.attrs = s.attrs || {}), - (this.innerHTML = s.innerHtml || s.innerHTML || ''); + (this.innerHTML = s.innerHtml || s.innerHTML || '')); } return ( (HtmlTag.prototype.setTagName = function (s) { - return (this.tagName = s), this; + return ((this.tagName = s), this); }), (HtmlTag.prototype.getTagName = function () { return this.tagName || ''; }), (HtmlTag.prototype.setAttr = function (s, o) { - return (this.getAttrs()[s] = o), this; + return ((this.getAttrs()[s] = o), this); }), (HtmlTag.prototype.getAttr = function (s) { return this.getAttrs()[s]; }), (HtmlTag.prototype.setAttrs = function (s) { - return Object.assign(this.getAttrs(), s), this; + return (Object.assign(this.getAttrs(), s), this); }), (HtmlTag.prototype.getAttrs = function () { return this.attrs || (this.attrs = {}); @@ -64636,10 +64705,9 @@ _ = i ? i.split(u) : [], w = s.split(u); (o = w.shift()); - ) -1 === indexOf(_, o) && _.push(o); - return (this.getAttrs().class = _.join(' ')), this; + return ((this.getAttrs().class = _.join(' ')), this); }), (HtmlTag.prototype.removeClass = function (s) { for ( @@ -64649,12 +64717,11 @@ _ = i ? i.split(u) : [], w = s.split(u); _.length && (o = w.shift()); - ) { var x = indexOf(_, o); -1 !== x && _.splice(x, 1); } - return (this.getAttrs().class = _.join(' ')), this; + return ((this.getAttrs().class = _.join(' ')), this); }), (HtmlTag.prototype.getClass = function () { return this.getAttrs().class || ''; @@ -64663,7 +64730,7 @@ return -1 !== (' ' + this.getClass() + ' ').indexOf(' ' + s + ' '); }), (HtmlTag.prototype.setInnerHTML = function (s) { - return (this.innerHTML = s), this; + return ((this.innerHTML = s), this); }), (HtmlTag.prototype.setInnerHtml = function (s) { return this.setInnerHTML(s); @@ -64693,13 +64760,13 @@ })(); var VC = (function () { function AnchorTagBuilder(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.newWindow = !1), (this.truncate = {}), (this.className = ''), (this.newWindow = s.newWindow || !1), (this.truncate = s.truncate || {}), - (this.className = s.className || ''); + (this.className = s.className || '')); } return ( (AnchorTagBuilder.prototype.build = function (s) { @@ -64761,7 +64828,7 @@ _ = Math.ceil(u), w = -1 * Math.floor(u), x = ''; - return w < 0 && (x = s.substr(w)), s.substr(0, _) + i + x; + return (w < 0 && (x = s.substr(w)), s.substr(0, _) + i + x); }; if (s.length <= o) return s; var w = o - _, @@ -64854,12 +64921,12 @@ })(), UC = (function () { function Match(s) { - (this.__jsduckDummyDocProp = null), + ((this.__jsduckDummyDocProp = null), (this.matchedText = ''), (this.offset = 0), (this.tagBuilder = s.tagBuilder), (this.matchedText = s.matchedText), - (this.offset = s.offset); + (this.offset = s.offset)); } return ( (Match.prototype.getMatchedText = function () { @@ -64902,9 +64969,9 @@ function __() { this.constructor = s; } - extendStatics(s, o), + (extendStatics(s, o), (s.prototype = - null === o ? Object.create(o) : ((__.prototype = o.prototype), new __())); + null === o ? Object.create(o) : ((__.prototype = o.prototype), new __()))); } var __assign = function () { return ( @@ -64926,7 +64993,7 @@ WC = (function (s) { function EmailMatch(o) { var i = s.call(this, o) || this; - return (i.email = ''), (i.email = o.email), i; + return ((i.email = ''), (i.email = o.email), i); } return ( tslib_es6_extends(EmailMatch, s), @@ -65033,7 +65100,7 @@ (MentionMatch.prototype.getCssClassSuffixes = function () { var o = s.prototype.getCssClassSuffixes.call(this), i = this.getServiceName(); - return i && o.push(i), o; + return (i && o.push(i), o); }), MentionMatch ); @@ -65136,7 +65203,7 @@ return s.replace(this.protocolRelativeRegex, ''); }), (UrlMatch.prototype.removeTrailingSlash = function (s) { - return '/' === s.charAt(s.length - 1) && (s = s.slice(0, -1)), s; + return ('/' === s.charAt(s.length - 1) && (s = s.slice(0, -1)), s); }), (UrlMatch.prototype.removePercentEncoding = function (s) { var o = s @@ -65155,7 +65222,7 @@ ); })(UC), YC = function YC(s) { - (this.__jsduckDummyDocProp = null), (this.tagBuilder = s.tagBuilder); + ((this.__jsduckDummyDocProp = null), (this.tagBuilder = s.tagBuilder)); }, XC = /[A-Za-z]/, ZC = /[\d]/, @@ -65202,7 +65269,7 @@ mO = (function (s) { function EmailMatcher() { var o = (null !== s && s.apply(this, arguments)) || this; - return (o.localPartCharRegex = dO), (o.strictTldRegex = fO), o; + return ((o.localPartCharRegex = dO), (o.strictTldRegex = fO), o); } return ( tslib_es6_extends(EmailMatcher, s), @@ -65219,7 +65286,6 @@ L = 0, B = x; j < w; - ) { var $ = s.charAt(j); switch (L) { @@ -65252,7 +65318,7 @@ } j++; } - return captureMatchIfValidAndReset(), _; + return (captureMatchIfValidAndReset(), _); function stateNonEmailAddress(s) { 'm' === s ? beginEmailMatch(1) : i.test(s) && beginEmailMatch(); } @@ -65309,10 +65375,10 @@ : captureMatchIfValidAndReset(); } function beginEmailMatch(s) { - void 0 === s && (s = 2), (L = s), (B = new gO({ idx: j })); + (void 0 === s && (s = 2), (L = s), (B = new gO({ idx: j }))); } function resetToNonEmailMatchState() { - (L = 0), (B = x); + ((L = 0), (B = x)); } function captureMatchIfValidAndReset() { if (B.hasDomainDot) { @@ -65333,10 +65399,10 @@ ); })(YC), gO = function gO(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.idx = void 0 !== s.idx ? s.idx : -1), (this.hasMailtoPrefix = !!s.hasMailtoPrefix), - (this.hasDomainDot = !!s.hasDomainDot); + (this.hasDomainDot = !!s.hasDomainDot)); }, yO = (function () { function UrlMatchValidator() {} @@ -65473,7 +65539,7 @@ }); if (ee) { var ie = i.indexOf(ee); - (i = i.substr(ie)), (L = L.substr(ie)), (U += ie); + ((i = i.substr(ie)), (L = L.substr(ie)), (U += ie)); } var ae = L ? 'scheme' : B ? 'www' : 'tld', le = !!L; @@ -65494,7 +65560,6 @@ }, j = this; null !== (o = i.exec(s)); - ) _loop_1(); return C; @@ -65534,7 +65599,7 @@ wO = (function (s) { function HashtagMatcher(o) { var i = s.call(this, o) || this; - return (i.serviceName = 'twitter'), (i.serviceName = o.serviceName), i; + return ((i.serviceName = 'twitter'), (i.serviceName = o.serviceName), i); } return ( tslib_es6_extends(HashtagMatcher, s), @@ -65548,7 +65613,6 @@ x = -1, C = 0; w < _; - ) { var j = s.charAt(w); switch (C) { @@ -65569,7 +65633,7 @@ } w++; } - return captureMatchIfValid(), u; + return (captureMatchIfValid(), u); function stateNone(s) { '#' === s ? ((C = 2), (x = w)) : lO.test(s) && (C = 1); } @@ -65616,7 +65680,7 @@ kO = (function (s) { function PhoneMatcher() { var o = (null !== s && s.apply(this, arguments)) || this; - return (o.matcherRegex = xO), o; + return ((o.matcherRegex = xO), o); } return ( tslib_es6_extends(PhoneMatcher, s), @@ -65624,7 +65688,6 @@ for ( var o, i = this.matcherRegex, u = this.tagBuilder, _ = []; null !== (o = i.exec(s)); - ) { var w = o[0], x = w.replace(/[^0-9,;#]/g, ''), @@ -65718,7 +65781,6 @@ $ = 0, V = C; j < L; - ) { var U = s.charAt(j); switch (B) { @@ -65932,21 +65994,21 @@ '>' === s ? emitTagAndPreviousTextNode() : '<' === s && startNewTag(); } function resetToDataState() { - (B = 0), (V = C); + ((B = 0), (V = C)); } function startNewTag() { - (B = 1), (V = new MO({ idx: j })); + ((B = 1), (V = new MO({ idx: j }))); } function emitTagAndPreviousTextNode() { var o = s.slice($, V.idx); - o && _(o, $), + (o && _(o, $), 'comment' === V.type ? w(V.idx) : 'doctype' === V.type ? x(V.idx) : (V.isOpening && i(V.name, V.idx), V.isClosing && u(V.name, V.idx)), resetToDataState(), - ($ = j + 1); + ($ = j + 1)); } function captureTagName() { var o = V.idx + (V.isClosing ? 2 : 1); @@ -65955,20 +66017,20 @@ $ < j && (function emitText() { var o = s.slice($, j); - _(o, $), ($ = j + 1); + (_(o, $), ($ = j + 1)); })(); } var MO = function MO(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.idx = void 0 !== s.idx ? s.idx : -1), (this.type = s.type || 'tag'), (this.name = s.name || ''), (this.isOpening = !!s.isOpening), - (this.isClosing = !!s.isClosing); + (this.isClosing = !!s.isClosing)); }, TO = (function () { function Autolinker(s) { - void 0 === s && (s = {}), + (void 0 === s && (s = {}), (this.version = Autolinker.version), (this.urls = {}), (this.email = !0), @@ -66001,17 +66063,17 @@ 'boolean' == typeof s.decodePercentEncoding ? s.decodePercentEncoding : this.decodePercentEncoding), - (this.sanitizeHtml = s.sanitizeHtml || !1); + (this.sanitizeHtml = s.sanitizeHtml || !1)); var o = this.mention; if (!1 !== o && -1 === ['twitter', 'instagram', 'soundcloud', 'tiktok'].indexOf(o)) throw new Error("invalid `mention` cfg '".concat(o, "' - see docs")); var i = this.hashtag; if (!1 !== i && -1 === SO.indexOf(i)) throw new Error("invalid `hashtag` cfg '".concat(i, "' - see docs")); - (this.truncate = this.normalizeTruncateCfg(s.truncate)), + ((this.truncate = this.normalizeTruncateCfg(s.truncate)), (this.className = s.className || this.className), (this.replaceFn = s.replaceFn || this.replaceFn), - (this.context = s.context || this); + (this.context = s.context || this)); } return ( (Autolinker.link = function (s, o) { @@ -66067,10 +66129,10 @@ if (!o.global) throw new Error("`splitRegex` must have the 'g' flag set"); for (var i, u = [], _ = 0; (i = o.exec(s)); ) - u.push(s.substring(_, i.index)), + (u.push(s.substring(_, i.index)), u.push(i[0]), - (_ = i.index + i[0].length); - return u.push(s.substring(_)), u; + (_ = i.index + i[0].length)); + return (u.push(s.substring(_)), u); })(s, /( | |<|<|>|>|"|"|')/gi), x = i; w.forEach(function (s, i) { @@ -66150,7 +66212,7 @@ ); }), (Autolinker.prototype.parseText = function (s, o) { - void 0 === o && (o = 0), (o = o || 0); + (void 0 === o && (o = 0), (o = o || 0)); for (var i = this.getMatchers(), u = [], _ = 0, w = i.length; _ < w; _++) { for (var x = i[_].parseMatches(s), C = 0, j = x.length; C < j; C++) x[C].setOffset(o + x[C].getOffset()); @@ -66163,11 +66225,11 @@ this.sanitizeHtml && (s = s.replace(//g, '>')); for (var o = this.parse(s), i = [], u = 0, _ = 0, w = o.length; _ < w; _++) { var x = o[_]; - i.push(s.substring(u, x.getOffset())), + (i.push(s.substring(u, x.getOffset())), i.push(this.createMatchReturnVal(x)), - (u = x.getOffset() + x.getMatchedText().length); + (u = x.getOffset() + x.getMatchedText().length)); } - return i.push(s.substring(u)), i.join(''); + return (i.push(s.substring(u)), i.join('')); }), (Autolinker.prototype.createMatchReturnVal = function (s) { var o; @@ -66305,8 +66367,8 @@ C.push({ type: 'text', content: V[j].text, level: B }), C.push({ type: 'link_close', level: --B }), (x = x.slice(L + V[j].text.length))); - x.length && C.push({ type: 'text', content: x, level: B }), - (z[i].children = _ = [].concat(_.slice(0, o), C, _.slice(o + 1))); + (x.length && C.push({ type: 'text', content: x, level: B }), + (z[i].children = _ = [].concat(_.slice(0, o), C, _.slice(o + 1)))); } } else for (o--; _[o].level !== w.level && 'link_open' !== _[o].type; ) o--; } @@ -66317,7 +66379,7 @@ LO = __webpack_require__.n(DO); LO().addHook && LO().addHook('beforeSanitizeElements', function (s) { - return s.href && s.setAttribute('rel', 'noopener noreferrer'), s; + return (s.href && s.setAttribute('rel', 'noopener noreferrer'), s); }); const BO = function Markdown({ source: s, @@ -66806,7 +66868,7 @@ }, setIsIncludedOptions = (s) => { let o = { key: s, shouldDispatchInit: !1, defaultValue: !0 }; - return 'no value' === u.get(s, 'no value') && (o.shouldDispatchInit = !0), o; + return ('no value' === u.get(s, 'no value') && (o.shouldDispatchInit = !0), o); }, ee = w('Markdown', !0), ie = w('modelExample'), @@ -66824,7 +66886,7 @@ Se = _e.get('examples', null), xe = Se?.map((s, i) => { const u = s?.get('value', null); - return u && (s = s.set('value', getDefaultRequestBodyValue(o, L, i, j), u)), s; + return (u && (s = s.set('value', getDefaultRequestBodyValue(o, L, i, j), u)), s); }); if (((_ = qe.List.isList(_) ? _ : (0, qe.List)()), !_e.size)) return null; const Te = 'object' === _e.getIn(['schema', 'type']), @@ -66884,10 +66946,10 @@ ce = i.getIn([x, 'errors']) || _, pe = u.get(x) || !1; let ye = j.getSampleSchema(C, !1, { includeWriteOnly: !0 }); - !1 === ye && (ye = 'false'), + (!1 === ye && (ye = 'false'), 0 === ye && (ye = '0'), 'string' != typeof ye && 'object' === Z && (ye = stringify(ye)), - 'string' == typeof ye && 'array' === Z && (ye = JSON.parse(ye)); + 'string' == typeof ye && 'array' === Z && (ye = JSON.parse(ye))); const be = 'string' === Z && ('binary' === ie || 'base64' === ie); return Pe.createElement( 'tr', @@ -67074,7 +67136,7 @@ (s.find((s) => s.get('url') === o) || (0, qe.OrderedMap)()).get('variables') || (0, qe.OrderedMap)(), C = 0 !== x.size; - (0, Pe.useEffect)(() => { + ((0, Pe.useEffect)(() => { o || i(s.first()?.get('url')); }, []), (0, Pe.useEffect)(() => { @@ -67083,7 +67145,7 @@ (_.get('variables') || (0, qe.OrderedMap)()).map((s, i) => { u({ server: o, key: i, val: s.get('default') || '' }); }); - }, [o, s]); + }, [o, s])); const j = (0, Pe.useCallback)( (s) => { i(s.target.value); @@ -67204,13 +67266,13 @@ class RequestBodyEditor extends Pe.PureComponent { static defaultProps = { onChange: tA, userHasEditedBody: !1 }; constructor(s, o) { - super(s, o), + (super(s, o), (this.state = { value: stringify(s.value) || s.defaultValue }), - s.onChange(s.value); + s.onChange(s.value)); } applyDefaultValue = (s) => { const { onChange: o, defaultValue: i } = s || this.props; - return this.setState({ value: i }), o(i); + return (this.setState({ value: i }), o(i)); }; onChange = (s) => { this.props.onChange(stringify(s)); @@ -67220,10 +67282,10 @@ this.setState({ value: o }, () => this.onChange(o)); }; UNSAFE_componentWillReceiveProps(s) { - this.props.value !== s.value && + (this.props.value !== s.value && s.value !== this.state.value && this.setState({ value: stringify(s.value) }), - !s.value && s.defaultValue && this.state.value && this.applyDefaultValue(s); + !s.value && s.defaultValue && this.state.value && this.applyDefaultValue(s)); } render() { let { getComponent: s, errors: o } = this.props, @@ -67257,7 +67319,7 @@ let { onChange: o } = this.props, { value: i, name: u } = s.target, _ = Object.assign({}, this.state.value); - u ? (_[u] = i) : (_ = i), this.setState({ value: _ }, () => o(this.state)); + (u ? (_[u] = i) : (_ = i), this.setState({ value: _ }, () => o(this.state))); }; render() { let { schema: s, getComponent: o, errSelectors: i, name: u } = this.props; @@ -67375,7 +67437,7 @@ class operation_servers_OperationServers extends Pe.Component { setSelectedServer = (s) => { const { path: o, method: i } = this.props; - return this.forceUpdate(), this.props.setSelectedServer(s, `${o}:${i}`); + return (this.forceUpdate(), this.props.setSelectedServer(s, `${o}:${i}`)); }; setServerVariableValue = (s) => { const { path: o, method: i } = this.props; @@ -67447,7 +67509,7 @@ operationLink: eA }, nA = new Remarkable('commonmark'); - nA.block.ruler.enable(['table']), nA.set({ linkTarget: '_blank' }); + (nA.block.ruler.enable(['table']), nA.set({ linkTarget: '_blank' })); const sA = OAS3ComponentWrapFactory( ({ source: s, @@ -67712,11 +67774,11 @@ var i, u; if ('string' != typeof o) { const { server: _, namespace: w } = o; - (u = _), + ((u = _), (i = w ? s.getIn([w, 'serverVariableValues', u]) - : s.getIn(['serverVariableValues', u])); - } else (u = o), (i = s.getIn(['serverVariableValues', u])); + : s.getIn(['serverVariableValues', u]))); + } else ((u = o), (i = s.getIn(['serverVariableValues', u]))); i = i || (0, qe.OrderedMap)(); let _ = u; return ( @@ -67814,7 +67876,7 @@ w.reduce((s, o) => s.setIn([o, 'errors'], (0, qe.fromJS)(_)), s) ); } - return console.warn('unexpected result: SET_REQUEST_BODY_VALIDATE_ERROR'), s; + return (console.warn('unexpected result: SET_REQUEST_BODY_VALIDATE_ERROR'), s); }, [bA]: (s, { payload: { path: o, method: i } }) => { const u = s.getIn(['requestData', o, i, 'bodyValue']); @@ -68225,7 +68287,7 @@ }; class auths_Auths extends Pe.Component { constructor(s, o) { - super(s, o), (this.state = {}); + (super(s, o), (this.state = {})); } onAuthChange = (s) => { let { name: o } = s; @@ -68240,7 +68302,7 @@ s.preventDefault(); let { authActions: o, definitions: i } = this.props, u = i.map((s, o) => o).toArray(); - this.setState(u.reduce((s, o) => ((s[o] = ''), s), {})), o.logoutWithPersistOption(u); + (this.setState(u.reduce((s, o) => ((s[o] = ''), s), {})), o.logoutWithPersistOption(u)); }; close = (s) => { s.preventDefault(); @@ -68744,7 +68806,7 @@ ? qe.Map.isMap(o) ? Object.entries(s.toJS()).reduce((s, [i, u]) => { const _ = o.get(i); - return (s[i] = _?.toJS() || u), s; + return ((s[i] = _?.toJS() || u), s); }, {}) : s.toJS() : {} @@ -68819,7 +68881,7 @@ B((s) => !s); }, []), ee = (0, Pe.useCallback)((s, o) => { - B(o), V(o); + (B(o), V(o)); }, []); return 0 === Object.keys(i).length ? null @@ -69017,7 +69079,7 @@ B((s) => !s); }, []), ee = (0, Pe.useCallback)((s, o) => { - B(o), V(o); + (B(o), V(o)); }, []); return 0 === Object.keys(i).length ? null @@ -69107,7 +69169,7 @@ B((s) => !s); }, []), ae = (0, Pe.useCallback)((s, o) => { - B(o), V(o); + (B(o), V(o)); }, []); return 0 === Object.keys(i).length ? null @@ -69495,21 +69557,21 @@ gt = useComponent('KeywordReadOnly'), yt = useComponent('KeywordWriteOnly'), vt = useComponent('ExpandDeepButton'); - (0, Pe.useEffect)(() => { + ((0, Pe.useEffect)(() => { $(C); }, [C]), (0, Pe.useEffect)(() => { $(B); - }, [B]); + }, [B])); const bt = (0, Pe.useCallback)( (s, o) => { - L(o), !o && $(!1), u(s, o, !1); + (L(o), !o && $(!1), u(s, o, !1)); }, [u] ), _t = (0, Pe.useCallback)( (s, o) => { - L(o), $(o), u(s, o, !0); + (L(o), $(o), u(s, o, !0)); }, [u] ); @@ -69835,7 +69897,7 @@ w((s) => !s); }, []), V = (0, Pe.useCallback)((s, o) => { - w(o), C(o); + (w(o), C(o)); }, []); return 0 === Object.keys(o).length ? null @@ -69929,7 +69991,7 @@ x((s) => !s); }, []), z = (0, Pe.useCallback)((s, o) => { - x(o), j(o); + (x(o), j(o)); }, []); return Array.isArray(o) && 0 !== o.length ? Pe.createElement( @@ -69991,7 +70053,7 @@ x((s) => !s); }, []), z = (0, Pe.useCallback)((s, o) => { - x(o), j(o); + (x(o), j(o)); }, []); return Array.isArray(o) && 0 !== o.length ? Pe.createElement( @@ -70053,7 +70115,7 @@ x((s) => !s); }, []), z = (0, Pe.useCallback)((s, o) => { - x(o), j(o); + (x(o), j(o)); }, []); return Array.isArray(o) && 0 !== o.length ? Pe.createElement( @@ -70185,7 +70247,7 @@ w((s) => !s); }, []), V = (0, Pe.useCallback)((s, o) => { - w(o), C(o); + (w(o), C(o)); }, []); return 'object' != typeof o || 0 === Object.keys(o).length ? null @@ -70257,7 +70319,7 @@ x((s) => !s); }, []), z = (0, Pe.useCallback)((s, o) => { - x(o), j(o); + (x(o), j(o)); }, []); return Array.isArray(o) && 0 !== o.length ? Pe.createElement( @@ -70883,7 +70945,7 @@ ] .filter(Boolean) .join(' | '); - return o.delete(s), x || 'any'; + return (o.delete(s), x || 'any'); }, isBooleanJSONSchema = (s) => 'boolean' == typeof s, hasKeyword = (s, o) => null !== s && 'object' == typeof s && Object.hasOwn(s, o), @@ -70971,15 +71033,15 @@ if (x || j) return `${B ? '<' : 'โ‰ค'} ${B ? _ : i}`; return null; })(s); - null !== u && o.push({ scope: 'number', value: u }), - s?.format && o.push({ scope: 'string', value: s.format }); + (null !== u && o.push({ scope: 'number', value: u }), + s?.format && o.push({ scope: 'string', value: s.format })); const _ = stringifyConstraintRange('characters', s?.minLength, s?.maxLength); - null !== _ && o.push({ scope: 'string', value: _ }), + (null !== _ && o.push({ scope: 'string', value: _ }), s?.pattern && o.push({ scope: 'string', value: `matches ${s?.pattern}` }), s?.contentMediaType && o.push({ scope: 'string', value: `media type: ${s.contentMediaType}` }), s?.contentEncoding && - o.push({ scope: 'string', value: `encoding: ${s.contentEncoding}` }); + o.push({ scope: 'string', value: `encoding: ${s.contentEncoding}` })); const w = stringifyConstraintRange( s?.hasUniqueItems ? 'unique items' : 'items', s?.minItems, @@ -70989,7 +71051,7 @@ const x = stringifyConstraintRange('contained items', s?.minContains, s?.maxContains); null !== x && o.push({ scope: 'array', value: x }); const C = stringifyConstraintRange('properties', s?.minProperties, s?.maxProperties); - return null !== C && o.push({ scope: 'object', value: C }), o; + return (null !== C && o.push({ scope: 'object', value: C }), o); }, getDependentRequired = (s, o) => o?.dependentRequired @@ -71067,7 +71129,9 @@ }, HOC = (o) => Pe.createElement(tI.Provider, { value: i }, Pe.createElement(s, o)); return ( - (HOC.contexts = { JSONSchemaContext: tI }), (HOC.displayName = s.displayName), HOC + (HOC.contexts = { JSONSchemaContext: tI }), + (HOC.displayName = s.displayName), + HOC ); }, json_schema_2020_12 = () => ({ @@ -71147,7 +71211,7 @@ (Number.isInteger(u) && u > 0 && (j = s.slice(0, u)), Number.isInteger(i) && i > 0) ) for (let s = 0; j.length < i; s += 1) j.push(j[s % j.length]); - return !0 === _ && (j = Array.from(new Set(j))), j; + return (!0 === _ && (j = Array.from(new Set(j))), j); })(o, s), object = () => { throw new Error('Not implemented'); @@ -71263,7 +71327,7 @@ x = 0; for (let s = 0; s < o.length; s++) for (w = (w << 8) | o.charCodeAt(s), x += 8; x >= 5; ) - (_ += i.charAt((w >>> (x - 5)) & 31)), (x -= 5); + ((_ += i.charAt((w >>> (x - 5)) & 31)), (x -= 5)); x > 0 && ((_ += i.charAt((w << (5 - x)) & 31)), (u = (8 - ((8 * o.length) % 5)) % 5)); for (let s = 0; s < u; s++) _ += '='; return _; @@ -71567,7 +71631,7 @@ u = inferTypeFromValue(o); i = 'string' == typeof u ? u : i; } - return o.delete(s), i || MI; + return (o.delete(s), i || MI); }, type_getType = (s) => inferType(s), typeCast = (s) => @@ -71620,14 +71684,14 @@ TI = merge_merge, main_sampleFromSchemaGeneric = (s, o = {}, i = void 0, u = !1) => { if (null == s && void 0 === i) return; - 'function' == typeof s?.toJS && (s = s.toJS()), (s = typeCast(s)); + ('function' == typeof s?.toJS && (s = s.toJS()), (s = typeCast(s))); let _ = void 0 !== i || hasExample(s); const w = !_ && Array.isArray(s.oneOf) && s.oneOf.length > 0, x = !_ && Array.isArray(s.anyOf) && s.anyOf.length > 0; if (!_ && (w || x)) { const i = typeCast(random_pick(w ? s.oneOf : s.anyOf)); - !(s = TI(s, i, o)).xml && i.xml && (s.xml = i.xml), - hasExample(s) && hasExample(i) && (_ = !0); + (!(s = TI(s, i, o)).xml && i.xml && (s.xml = i.xml), + hasExample(s) && hasExample(i) && (_ = !0)); } const C = {}; let { xml: j, properties: L, additionalProperties: B, items: $, contains: V } = s || {}, @@ -71748,9 +71812,9 @@ ((ce[s]?.readOnly && !z) || (ce[s]?.writeOnly && !Y) || (ce[s]?.xml?.attribute ? (C[ce[s].xml.name || s] = _[s]) : pe(s, _[s]))); - return hs()(C) || le[Z].push({ _attr: C }), le; + return (hs()(C) || le[Z].push({ _attr: C }), le); } - return (le[Z] = hs()(C) ? _ : [{ _attr: C }, _]), le; + return ((le[Z] = hs()(C) ? _ : [{ _attr: C }, _]), le); } if ('array' === U) { let i = []; @@ -71806,10 +71870,10 @@ pe(s)); if ((u && C && le[Z].push({ _attr: C }), hasExceededMaxProperties())) return le; if (predicates_isBooleanJSONSchema(B) && B) - u + (u ? le[Z].push({ additionalProp: 'Anything can be here' }) : (le.additionalProp1 = {}), - de++; + de++); else if (isJSONSchemaObject(B)) { const i = B, _ = main_sampleFromSchemaGeneric(i, o, void 0, u); @@ -71824,7 +71888,7 @@ if (hasExceededMaxProperties()) return le; if (u) { const o = {}; - (o['additionalProp' + s] = _.notagname), le[Z].push(o); + ((o['additionalProp' + s] = _.notagname), le[Z].push(o)); } else le['additionalProp' + s] = _; de++; } @@ -71873,10 +71937,10 @@ x = w.jsonSchema202012.getJsonSampleSchema(o, i, u, _); let C; try { - (C = mn.dump(mn.load(x), { lineWidth: -1 }, { schema: nn })), - '\n' === C[C.length - 1] && (C = C.slice(0, C.length - 1)); + ((C = mn.dump(mn.load(x), { lineWidth: -1 }, { schema: nn })), + '\n' === C[C.length - 1] && (C = C.slice(0, C.length - 1))); } catch (s) { - return console.error(s), 'error: could not generate yaml example'; + return (console.error(s), 'error: could not generate yaml example'); } return C.replace(/\t/g, ' '); }, @@ -71977,7 +72041,7 @@ const s = {}; return ( (s.promise = new Promise((o, i) => { - (s.resolve = o), (s.reject = i); + ((s.resolve = o), (s.reject = i)); })), s ); @@ -72198,13 +72262,13 @@ const _ = []; for (const s of o) { const o = { ...s }; - Object.hasOwn(o, 'domNode') && ((i = o.domNode), delete o.domNode), + (Object.hasOwn(o, 'domNode') && ((i = o.domNode), delete o.domNode), Object.hasOwn(o, 'urls.primaryName') ? ((u = o['urls.primaryName']), delete o['urls.primaryName']) : Array.isArray(o.urls) && Object.hasOwn(o.urls, 'primaryName') && ((u = o.urls.primaryName), delete o.urls.primaryName), - _.push(o); + _.push(o)); } const w = We()(s, ..._); return ( @@ -72223,7 +72287,7 @@ x.register([u.plugins, w]); const C = x.getSystem(), persistConfigs = (s) => { - x.setConfigs(s), C.configsActions.loaded(); + (x.setConfigs(s), C.configsActions.loaded()); }, updateSpec = (s) => { !o.url && 'object' == typeof s.spec && Object.keys(s.spec).length > 0 @@ -72250,12 +72314,12 @@ const { configUrl: s } = u, i = await sources_url({ url: s, system: C })(u), _ = SwaggerUI.config.merge({}, u, i, o); - persistConfigs(_), null !== i && updateSpec(_), render(_); + (persistConfigs(_), null !== i && updateSpec(_), render(_)); })(), C) : (persistConfigs(u), updateSpec(u), render(u), C); } - (SwaggerUI.System = Store), + ((SwaggerUI.System = Store), (SwaggerUI.config = { defaults: FI, merge: config_merge, @@ -72289,7 +72353,7 @@ SyntaxHighlighting: syntax_highlighting, Versions: versions, SafeRender: safe_render - }); + })); const WI = SwaggerUI; })(), (_ = _.default) diff --git a/backend/open_webui/storage/provider.py b/backend/open_webui/storage/provider.py index 425d10c812..3c29462349 100644 --- a/backend/open_webui/storage/provider.py +++ b/backend/open_webui/storage/provider.py @@ -43,9 +43,7 @@ def get_file(self, file_path: str) -> str: pass @abstractmethod - def upload_file( - self, file: BinaryIO, filename: str, tags: Dict[str, str] - ) -> Tuple[bytes, str]: + def upload_file(self, file: BinaryIO, filename: str, tags: Dict[str, str]) -> Tuple[bytes, str]: pass @abstractmethod @@ -59,14 +57,12 @@ def delete_file(self, file_path: str) -> None: class LocalStorageProvider(StorageProvider): @staticmethod - def upload_file( - file: BinaryIO, filename: str, tags: Dict[str, str] - ) -> Tuple[bytes, str]: + def upload_file(file: BinaryIO, filename: str, tags: Dict[str, str]) -> Tuple[bytes, str]: contents = file.read() if not contents: raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT) - file_path = f"{UPLOAD_DIR}/{filename}" - with open(file_path, "wb") as f: + file_path = f'{UPLOAD_DIR}/{filename}' + with open(file_path, 'wb') as f: f.write(contents) return contents, file_path @@ -78,12 +74,12 @@ def get_file(file_path: str) -> str: @staticmethod def delete_file(file_path: str) -> None: """Handles deletion of the file from local storage.""" - filename = file_path.split("/")[-1] - file_path = f"{UPLOAD_DIR}/{filename}" + filename = file_path.split('/')[-1] + file_path = f'{UPLOAD_DIR}/{filename}' if os.path.isfile(file_path): os.remove(file_path) else: - log.warning(f"File {file_path} not found in local storage.") + log.warning(f'File {file_path} not found in local storage.') @staticmethod def delete_all_files() -> None: @@ -97,27 +93,27 @@ def delete_all_files() -> None: elif os.path.isdir(file_path): shutil.rmtree(file_path) # Remove the directory except Exception as e: - log.exception(f"Failed to delete {file_path}. Reason: {e}") + log.exception(f'Failed to delete {file_path}. Reason: {e}') else: - log.warning(f"Directory {UPLOAD_DIR} not found in local storage.") + log.warning(f'Directory {UPLOAD_DIR} not found in local storage.') class S3StorageProvider(StorageProvider): def __init__(self): config = Config( s3={ - "use_accelerate_endpoint": S3_USE_ACCELERATE_ENDPOINT, - "addressing_style": S3_ADDRESSING_STYLE, + 'use_accelerate_endpoint': S3_USE_ACCELERATE_ENDPOINT, + 'addressing_style': S3_ADDRESSING_STYLE, }, # KIT change - see https://github.com/boto/boto3/issues/4400#issuecomment-2600742103โˆ† - request_checksum_calculation="when_required", - response_checksum_validation="when_required", + request_checksum_calculation='when_required', + response_checksum_validation='when_required', ) # If access key and secret are provided, use them for authentication if S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY: self.s3_client = boto3.client( - "s3", + 's3', region_name=S3_REGION_NAME, endpoint_url=S3_ENDPOINT_URL, aws_access_key_id=S3_ACCESS_KEY_ID, @@ -128,49 +124,40 @@ def __init__(self): # If no explicit credentials are provided, fall back to default AWS credentials # This supports workload identity (IAM roles for EC2, EKS, etc.) self.s3_client = boto3.client( - "s3", + 's3', region_name=S3_REGION_NAME, endpoint_url=S3_ENDPOINT_URL, config=config, ) self.bucket_name = S3_BUCKET_NAME - self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else "" + self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else '' @staticmethod def sanitize_tag_value(s: str) -> str: """Only include S3 allowed characters.""" - return re.sub(r"[^a-zA-Z0-9 รครถรผร„ร–รœรŸ\+\-=\._:/@]", "", s) + return re.sub(r'[^a-zA-Z0-9 รครถรผร„ร–รœรŸ\+\-=\._:/@]', '', s) - def upload_file( - self, file: BinaryIO, filename: str, tags: Dict[str, str] - ) -> Tuple[bytes, str]: + def upload_file(self, file: BinaryIO, filename: str, tags: Dict[str, str]) -> Tuple[bytes, str]: """Handles uploading of the file to S3 storage.""" _, file_path = LocalStorageProvider.upload_file(file, filename, tags) s3_key = os.path.join(self.key_prefix, filename) try: self.s3_client.upload_file(file_path, self.bucket_name, s3_key) if S3_ENABLE_TAGGING and tags: - sanitized_tags = { - self.sanitize_tag_value(k): self.sanitize_tag_value(v) - for k, v in tags.items() - } - tagging = { - "TagSet": [ - {"Key": k, "Value": v} for k, v in sanitized_tags.items() - ] - } + sanitized_tags = {self.sanitize_tag_value(k): self.sanitize_tag_value(v) for k, v in tags.items()} + tagging = {'TagSet': [{'Key': k, 'Value': v} for k, v in sanitized_tags.items()]} self.s3_client.put_object_tagging( Bucket=self.bucket_name, Key=s3_key, Tagging=tagging, ) return ( - open(file_path, "rb").read(), - f"s3://{self.bucket_name}/{s3_key}", + open(file_path, 'rb').read(), + f's3://{self.bucket_name}/{s3_key}', ) except ClientError as e: - raise RuntimeError(f"Error uploading file to S3: {e}") + raise RuntimeError(f'Error uploading file to S3: {e}') def get_file(self, file_path: str) -> str: """Handles downloading of the file from S3 storage.""" @@ -180,7 +167,7 @@ def get_file(self, file_path: str) -> str: self.s3_client.download_file(self.bucket_name, s3_key, local_file_path) return local_file_path except ClientError as e: - raise RuntimeError(f"Error downloading file from S3: {e}") + raise RuntimeError(f'Error downloading file from S3: {e}') def delete_file(self, file_path: str) -> None: """Handles deletion of the file from S3 storage.""" @@ -188,7 +175,7 @@ def delete_file(self, file_path: str) -> None: s3_key = self._extract_s3_key(file_path) self.s3_client.delete_object(Bucket=self.bucket_name, Key=s3_key) except ClientError as e: - raise RuntimeError(f"Error deleting file from S3: {e}") + raise RuntimeError(f'Error deleting file from S3: {e}') # Always delete from local storage LocalStorageProvider.delete_file(file_path) @@ -197,27 +184,25 @@ def delete_all_files(self) -> None: """Handles deletion of all files from S3 storage.""" try: response = self.s3_client.list_objects_v2(Bucket=self.bucket_name) - if "Contents" in response: - for content in response["Contents"]: + if 'Contents' in response: + for content in response['Contents']: # Skip objects that were not uploaded from open-webui in the first place - if not content["Key"].startswith(self.key_prefix): + if not content['Key'].startswith(self.key_prefix): continue - self.s3_client.delete_object( - Bucket=self.bucket_name, Key=content["Key"] - ) + self.s3_client.delete_object(Bucket=self.bucket_name, Key=content['Key']) except ClientError as e: - raise RuntimeError(f"Error deleting all files from S3: {e}") + raise RuntimeError(f'Error deleting all files from S3: {e}') # Always delete from local storage LocalStorageProvider.delete_all_files() # The s3 key is the name assigned to an object. It excludes the bucket name, but includes the internal path and the file name. def _extract_s3_key(self, full_file_path: str) -> str: - return "/".join(full_file_path.split("//")[1].split("/")[1:]) + return '/'.join(full_file_path.split('//')[1].split('/')[1:]) def _get_local_file_path(self, s3_key: str) -> str: - return f"{UPLOAD_DIR}/{s3_key.split('/')[-1]}" + return f'{UPLOAD_DIR}/{s3_key.split("/")[-1]}' class GCSStorageProvider(StorageProvider): @@ -235,38 +220,36 @@ def __init__(self): self.gcs_client = storage.Client() self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME) - def upload_file( - self, file: BinaryIO, filename: str, tags: Dict[str, str] - ) -> Tuple[bytes, str]: + def upload_file(self, file: BinaryIO, filename: str, tags: Dict[str, str]) -> Tuple[bytes, str]: """Handles uploading of the file to GCS storage.""" contents, file_path = LocalStorageProvider.upload_file(file, filename, tags) try: blob = self.bucket.blob(filename) blob.upload_from_filename(file_path) - return contents, "gs://" + self.bucket_name + "/" + filename + return contents, 'gs://' + self.bucket_name + '/' + filename except GoogleCloudError as e: - raise RuntimeError(f"Error uploading file to GCS: {e}") + raise RuntimeError(f'Error uploading file to GCS: {e}') def get_file(self, file_path: str) -> str: """Handles downloading of the file from GCS storage.""" try: - filename = file_path.removeprefix("gs://").split("/")[1] - local_file_path = f"{UPLOAD_DIR}/{filename}" + filename = file_path.removeprefix('gs://').split('/')[1] + local_file_path = f'{UPLOAD_DIR}/{filename}' blob = self.bucket.get_blob(filename) blob.download_to_filename(local_file_path) return local_file_path except NotFound as e: - raise RuntimeError(f"Error downloading file from GCS: {e}") + raise RuntimeError(f'Error downloading file from GCS: {e}') def delete_file(self, file_path: str) -> None: """Handles deletion of the file from GCS storage.""" try: - filename = file_path.removeprefix("gs://").split("/")[1] + filename = file_path.removeprefix('gs://').split('/')[1] blob = self.bucket.get_blob(filename) blob.delete() except NotFound as e: - raise RuntimeError(f"Error deleting file from GCS: {e}") + raise RuntimeError(f'Error deleting file from GCS: {e}') # Always delete from local storage LocalStorageProvider.delete_file(file_path) @@ -280,7 +263,7 @@ def delete_all_files(self) -> None: blob.delete() except NotFound as e: - raise RuntimeError(f"Error deleting all files from GCS: {e}") + raise RuntimeError(f'Error deleting all files from GCS: {e}') # Always delete from local storage LocalStorageProvider.delete_all_files() @@ -294,51 +277,43 @@ def __init__(self): if storage_key: # Configure using the Azure Storage Account Endpoint and Key - self.blob_service_client = BlobServiceClient( - account_url=self.endpoint, credential=storage_key - ) + self.blob_service_client = BlobServiceClient(account_url=self.endpoint, credential=storage_key) else: # Configure using the Azure Storage Account Endpoint and DefaultAzureCredential # If the key is not configured, then the DefaultAzureCredential will be used to support Managed Identity authentication - self.blob_service_client = BlobServiceClient( - account_url=self.endpoint, credential=DefaultAzureCredential() - ) - self.container_client = self.blob_service_client.get_container_client( - self.container_name - ) + self.blob_service_client = BlobServiceClient(account_url=self.endpoint, credential=DefaultAzureCredential()) + self.container_client = self.blob_service_client.get_container_client(self.container_name) - def upload_file( - self, file: BinaryIO, filename: str, tags: Dict[str, str] - ) -> Tuple[bytes, str]: + def upload_file(self, file: BinaryIO, filename: str, tags: Dict[str, str]) -> Tuple[bytes, str]: """Handles uploading of the file to Azure Blob Storage.""" contents, file_path = LocalStorageProvider.upload_file(file, filename, tags) try: blob_client = self.container_client.get_blob_client(filename) blob_client.upload_blob(contents, overwrite=True) - return contents, f"{self.endpoint}/{self.container_name}/{filename}" + return contents, f'{self.endpoint}/{self.container_name}/{filename}' except Exception as e: - raise RuntimeError(f"Error uploading file to Azure Blob Storage: {e}") + raise RuntimeError(f'Error uploading file to Azure Blob Storage: {e}') def get_file(self, file_path: str) -> str: """Handles downloading of the file from Azure Blob Storage.""" try: - filename = file_path.split("/")[-1] - local_file_path = f"{UPLOAD_DIR}/{filename}" + filename = file_path.split('/')[-1] + local_file_path = f'{UPLOAD_DIR}/{filename}' blob_client = self.container_client.get_blob_client(filename) - with open(local_file_path, "wb") as download_file: + with open(local_file_path, 'wb') as download_file: download_file.write(blob_client.download_blob().readall()) return local_file_path except ResourceNotFoundError as e: - raise RuntimeError(f"Error downloading file from Azure Blob Storage: {e}") + raise RuntimeError(f'Error downloading file from Azure Blob Storage: {e}') def delete_file(self, file_path: str) -> None: """Handles deletion of the file from Azure Blob Storage.""" try: - filename = file_path.split("/")[-1] + filename = file_path.split('/')[-1] blob_client = self.container_client.get_blob_client(filename) blob_client.delete_blob() except ResourceNotFoundError as e: - raise RuntimeError(f"Error deleting file from Azure Blob Storage: {e}") + raise RuntimeError(f'Error deleting file from Azure Blob Storage: {e}') # Always delete from local storage LocalStorageProvider.delete_file(file_path) @@ -350,23 +325,23 @@ def delete_all_files(self) -> None: for blob in blobs: self.container_client.delete_blob(blob.name) except Exception as e: - raise RuntimeError(f"Error deleting all files from Azure Blob Storage: {e}") + raise RuntimeError(f'Error deleting all files from Azure Blob Storage: {e}') # Always delete from local storage LocalStorageProvider.delete_all_files() def get_storage_provider(storage_provider: str): - if storage_provider == "local": + if storage_provider == 'local': Storage = LocalStorageProvider() - elif storage_provider == "s3": + elif storage_provider == 's3': Storage = S3StorageProvider() - elif storage_provider == "gcs": + elif storage_provider == 'gcs': Storage = GCSStorageProvider() - elif storage_provider == "azure": + elif storage_provider == 'azure': Storage = AzureStorageProvider() else: - raise RuntimeError(f"Unsupported storage provider: {storage_provider}") + raise RuntimeError(f'Unsupported storage provider: {storage_provider}') return Storage diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index 04dfb5a556..30754cfc48 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -17,9 +17,9 @@ item_tasks = {} -REDIS_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks" -REDIS_ITEM_TASKS_KEY = f"{REDIS_KEY_PREFIX}:tasks:item" -REDIS_PUBSUB_CHANNEL = f"{REDIS_KEY_PREFIX}:tasks:commands" +REDIS_TASKS_KEY = f'{REDIS_KEY_PREFIX}:tasks' +REDIS_ITEM_TASKS_KEY = f'{REDIS_KEY_PREFIX}:tasks:item' +REDIS_PUBSUB_CHANNEL = f'{REDIS_KEY_PREFIX}:tasks:commands' async def redis_task_command_listener(app): @@ -28,17 +28,17 @@ async def redis_task_command_listener(app): await pubsub.subscribe(REDIS_PUBSUB_CHANNEL) async for message in pubsub.listen(): - if message["type"] != "message": + if message['type'] != 'message': continue try: - command = json.loads(message["data"]) - if command.get("action") == "stop": - task_id = command.get("task_id") + command = json.loads(message['data']) + if command.get('action') == 'stop': + task_id = command.get('task_id') local_task = tasks.get(task_id) if local_task: local_task.cancel() except Exception as e: - log.exception(f"Error handling distributed task command: {e}") + log.exception(f'Error handling distributed task command: {e}') ### ------------------------------ @@ -48,9 +48,9 @@ async def redis_task_command_listener(app): async def redis_save_task(redis: Redis, task_id: str, item_id: Optional[str]): pipe = redis.pipeline() - pipe.hset(REDIS_TASKS_KEY, task_id, item_id or "") + pipe.hset(REDIS_TASKS_KEY, task_id, item_id or '') if item_id: - pipe.sadd(f"{REDIS_ITEM_TASKS_KEY}:{item_id}", task_id) + pipe.sadd(f'{REDIS_ITEM_TASKS_KEY}:{item_id}', task_id) await pipe.execute() @@ -58,10 +58,13 @@ async def redis_cleanup_task(redis: Redis, task_id: str, item_id: Optional[str]) pipe = redis.pipeline() pipe.hdel(REDIS_TASKS_KEY, task_id) if item_id: - pipe.srem(f"{REDIS_ITEM_TASKS_KEY}:{item_id}", task_id) - if (await pipe.scard(f"{REDIS_ITEM_TASKS_KEY}:{item_id}").execute())[-1] == 0: - pipe.delete(f"{REDIS_ITEM_TASKS_KEY}:{item_id}") # Remove if empty set - await pipe.execute() + pipe.srem(f'{REDIS_ITEM_TASKS_KEY}:{item_id}', task_id) + await pipe.execute() + # Remove the set key entirely if no tasks remain for this item + if await redis.scard(f'{REDIS_ITEM_TASKS_KEY}:{item_id}') == 0: + await redis.delete(f'{REDIS_ITEM_TASKS_KEY}:{item_id}') + else: + await pipe.execute() async def redis_list_tasks(redis: Redis) -> List[str]: @@ -69,15 +72,15 @@ async def redis_list_tasks(redis: Redis) -> 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}")) + return list(await redis.smembers(f'{REDIS_ITEM_TASKS_KEY}:{item_id}')) async def redis_send_command(redis: Redis, command: dict): command_json = json.dumps(command) # RedisCluster doesn't expose publish() directly, but the # PUBLISH command broadcasts across all cluster nodes server-side. - if hasattr(redis, "nodes_manager"): - await redis.execute_command("PUBLISH", REDIS_PUBSUB_CHANNEL, command_json) + if hasattr(redis, 'nodes_manager'): + await redis.execute_command('PUBLISH', REDIS_PUBSUB_CHANNEL, command_json) else: await redis.publish(REDIS_PUBSUB_CHANNEL, command_json) @@ -106,9 +109,7 @@ async def create_task(redis, coroutine, id=None): task = asyncio.create_task(coroutine) # Create the task # Add a done callback for cleanup - task.add_done_callback( - lambda t: asyncio.create_task(cleanup_task(redis, task_id, id)) - ) + task.add_done_callback(lambda t: asyncio.create_task(cleanup_task(redis, task_id, id))) tasks[task_id] = task # If an ID is provided, associate the task with that ID @@ -146,32 +147,36 @@ async def stop_task(redis, task_id: str): Cancel a running task and remove it from the global task list. """ if redis: + # Look up the item_id before cleanup so we can remove the set entry too + item_id = await redis.hget(REDIS_TASKS_KEY, task_id) # PUBSUB: All instances check if they have this task, and stop if so. await redis_send_command( redis, { - "action": "stop", - "task_id": task_id, + 'action': 'stop', + 'task_id': task_id, }, ) - # Optionally check if task_id still in Redis a few moments later for feedback? - return {"status": True, "message": f"Stop signal sent for {task_id}"} + # Always clean Redis directly โ€” hdel/srem are idempotent, safe even + # if the done_callback on the owning process also fires cleanup. + await redis_cleanup_task(redis, task_id, item_id or None) + return {'status': True, 'message': f'Task {task_id} stopped.'} task = tasks.pop(task_id, None) if not task: - return {"status": False, "message": f"Task with ID {task_id} not found."} + return {'status': False, 'message': f'Task with ID {task_id} not found.'} task.cancel() # Request task cancellation try: await task # Wait for the task to handle the cancellation except asyncio.CancelledError: # Task successfully canceled - return {"status": True, "message": f"Task {task_id} successfully stopped."} + return {'status': True, 'message': f'Task {task_id} successfully stopped.'} if task.cancelled() or task.done(): - return {"status": True, "message": f"Task {task_id} successfully cancelled."} + return {'status': True, 'message': f'Task {task_id} successfully cancelled.'} - return {"status": True, "message": f"Cancellation requested for {task_id}."} + return {'status': True, 'message': f'Cancellation requested for {task_id}.'} async def stop_item_tasks(redis: Redis, item_id: str): @@ -180,14 +185,14 @@ async def stop_item_tasks(redis: Redis, item_id: str): """ task_ids = await list_task_ids_by_item_id(redis, item_id) if not task_ids: - return {"status": True, "message": f"No tasks found for item {item_id}."} + return {'status': True, 'message': f'No tasks found for item {item_id}.'} for task_id in task_ids: result = await stop_task(redis, task_id) - if not result["status"]: + if not result['status']: return result # Return the first failure - return {"status": True, "message": f"All tasks for item {item_id} stopped."} + return {'status': True, 'message': f'All tasks for item {item_id} stopped.'} async def has_active_tasks(redis, chat_id: str) -> bool: diff --git a/backend/open_webui/test/apps/webui/routers/test_auths.py b/backend/open_webui/test/apps/webui/routers/test_auths.py index f0f69e26d2..9f9ae9bc5c 100644 --- a/backend/open_webui/test/apps/webui/routers/test_auths.py +++ b/backend/open_webui/test/apps/webui/routers/test_auths.py @@ -3,7 +3,7 @@ class TestAuths(AbstractPostgresTest): - BASE_PATH = "/api/v1/auths" + BASE_PATH = '/api/v1/auths' def setup_class(cls): super().setup_class() @@ -15,171 +15,167 @@ def setup_class(cls): def test_get_session_user(self): with mock_webui_user(): - response = self.fast_api_client.get(self.create_url("")) + response = self.fast_api_client.get(self.create_url('')) assert response.status_code == 200 assert response.json() == { - "id": "1", - "name": "John Doe", - "email": "john.doe@openwebui.com", - "role": "user", - "profile_image_url": "/user.png", + 'id': '1', + 'name': 'John Doe', + 'email': 'john.doe@openwebui.com', + 'role': 'user', + 'profile_image_url': '/user.png', } def test_update_profile(self): from open_webui.utils.auth import get_password_hash user = self.auths.insert_new_auth( - email="john.doe@openwebui.com", - password=get_password_hash("old_password"), - name="John Doe", - profile_image_url="/user.png", - role="user", + email='john.doe@openwebui.com', + password=get_password_hash('old_password'), + name='John Doe', + profile_image_url='/user.png', + role='user', ) with mock_webui_user(id=user.id): response = self.fast_api_client.post( - self.create_url("/update/profile"), - json={"name": "John Doe 2", "profile_image_url": "/user2.png"}, + self.create_url('/update/profile'), + json={'name': 'John Doe 2', 'profile_image_url': '/user2.png'}, ) assert response.status_code == 200 db_user = self.users.get_user_by_id(user.id) - assert db_user.name == "John Doe 2" - assert db_user.profile_image_url == "/user2.png" + assert db_user.name == 'John Doe 2' + assert db_user.profile_image_url == '/user2.png' def test_update_password(self): from open_webui.utils.auth import get_password_hash user = self.auths.insert_new_auth( - email="john.doe@openwebui.com", - password=get_password_hash("old_password"), - name="John Doe", - profile_image_url="/user.png", - role="user", + email='john.doe@openwebui.com', + password=get_password_hash('old_password'), + name='John Doe', + profile_image_url='/user.png', + role='user', ) with mock_webui_user(id=user.id): response = self.fast_api_client.post( - self.create_url("/update/password"), - json={"password": "old_password", "new_password": "new_password"}, + self.create_url('/update/password'), + json={'password': 'old_password', 'new_password': 'new_password'}, ) assert response.status_code == 200 - old_auth = self.auths.authenticate_user( - "john.doe@openwebui.com", "old_password" - ) + old_auth = self.auths.authenticate_user('john.doe@openwebui.com', 'old_password') assert old_auth is None - new_auth = self.auths.authenticate_user( - "john.doe@openwebui.com", "new_password" - ) + new_auth = self.auths.authenticate_user('john.doe@openwebui.com', 'new_password') assert new_auth is not None def test_signin(self): from open_webui.utils.auth import get_password_hash user = self.auths.insert_new_auth( - email="john.doe@openwebui.com", - password=get_password_hash("password"), - name="John Doe", - profile_image_url="/user.png", - role="user", + email='john.doe@openwebui.com', + password=get_password_hash('password'), + name='John Doe', + profile_image_url='/user.png', + role='user', ) response = self.fast_api_client.post( - self.create_url("/signin"), - json={"email": "john.doe@openwebui.com", "password": "password"}, + self.create_url('/signin'), + json={'email': 'john.doe@openwebui.com', 'password': 'password'}, ) assert response.status_code == 200 data = response.json() - assert data["id"] == user.id - assert data["name"] == "John Doe" - assert data["email"] == "john.doe@openwebui.com" - assert data["role"] == "user" - assert data["profile_image_url"] == "/user.png" - assert data["token"] is not None and len(data["token"]) > 0 - assert data["token_type"] == "Bearer" + assert data['id'] == user.id + assert data['name'] == 'John Doe' + assert data['email'] == 'john.doe@openwebui.com' + assert data['role'] == 'user' + assert data['profile_image_url'] == '/user.png' + assert data['token'] is not None and len(data['token']) > 0 + assert data['token_type'] == 'Bearer' def test_signup(self): response = self.fast_api_client.post( - self.create_url("/signup"), + self.create_url('/signup'), json={ - "name": "John Doe", - "email": "john.doe@openwebui.com", - "password": "password", + 'name': 'John Doe', + 'email': 'john.doe@openwebui.com', + 'password': 'password', }, ) assert response.status_code == 200 data = response.json() - assert data["id"] is not None and len(data["id"]) > 0 - assert data["name"] == "John Doe" - assert data["email"] == "john.doe@openwebui.com" - assert data["role"] in ["admin", "user", "pending"] - assert data["profile_image_url"] == "/user.png" - assert data["token"] is not None and len(data["token"]) > 0 - assert data["token_type"] == "Bearer" + assert data['id'] is not None and len(data['id']) > 0 + assert data['name'] == 'John Doe' + assert data['email'] == 'john.doe@openwebui.com' + assert data['role'] in ['admin', 'user', 'pending'] + assert data['profile_image_url'] == '/user.png' + assert data['token'] is not None and len(data['token']) > 0 + assert data['token_type'] == 'Bearer' def test_add_user(self): with mock_webui_user(): response = self.fast_api_client.post( - self.create_url("/add"), + self.create_url('/add'), json={ - "name": "John Doe 2", - "email": "john.doe2@openwebui.com", - "password": "password2", - "role": "admin", + 'name': 'John Doe 2', + 'email': 'john.doe2@openwebui.com', + 'password': 'password2', + 'role': 'admin', }, ) assert response.status_code == 200 data = response.json() - assert data["id"] is not None and len(data["id"]) > 0 - assert data["name"] == "John Doe 2" - assert data["email"] == "john.doe2@openwebui.com" - assert data["role"] == "admin" - assert data["profile_image_url"] == "/user.png" - assert data["token"] is not None and len(data["token"]) > 0 - assert data["token_type"] == "Bearer" + assert data['id'] is not None and len(data['id']) > 0 + assert data['name'] == 'John Doe 2' + assert data['email'] == 'john.doe2@openwebui.com' + assert data['role'] == 'admin' + assert data['profile_image_url'] == '/user.png' + assert data['token'] is not None and len(data['token']) > 0 + assert data['token_type'] == 'Bearer' def test_get_admin_details(self): self.auths.insert_new_auth( - email="john.doe@openwebui.com", - password="password", - name="John Doe", - profile_image_url="/user.png", - role="admin", + email='john.doe@openwebui.com', + password='password', + name='John Doe', + profile_image_url='/user.png', + role='admin', ) with mock_webui_user(): - response = self.fast_api_client.get(self.create_url("/admin/details")) + response = self.fast_api_client.get(self.create_url('/admin/details')) assert response.status_code == 200 assert response.json() == { - "name": "John Doe", - "email": "john.doe@openwebui.com", + 'name': 'John Doe', + 'email': 'john.doe@openwebui.com', } def test_create_api_key_(self): user = self.auths.insert_new_auth( - email="john.doe@openwebui.com", - password="password", - name="John Doe", - profile_image_url="/user.png", - role="admin", + email='john.doe@openwebui.com', + password='password', + name='John Doe', + profile_image_url='/user.png', + role='admin', ) with mock_webui_user(id=user.id): - response = self.fast_api_client.post(self.create_url("/api_key")) + response = self.fast_api_client.post(self.create_url('/api_key')) assert response.status_code == 200 data = response.json() - assert data["api_key"] is not None - assert len(data["api_key"]) > 0 + assert data['api_key'] is not None + assert len(data['api_key']) > 0 def test_delete_api_key(self): user = self.auths.insert_new_auth( - email="john.doe@openwebui.com", - password="password", - name="John Doe", - profile_image_url="/user.png", - role="admin", + email='john.doe@openwebui.com', + password='password', + name='John Doe', + profile_image_url='/user.png', + role='admin', ) - self.users.update_user_api_key_by_id(user.id, "abc") + self.users.update_user_api_key_by_id(user.id, 'abc') with mock_webui_user(id=user.id): - response = self.fast_api_client.delete(self.create_url("/api_key")) + response = self.fast_api_client.delete(self.create_url('/api_key')) assert response.status_code == 200 assert response.json() == True db_user = self.users.get_user_by_id(user.id) @@ -187,14 +183,14 @@ def test_delete_api_key(self): def test_get_api_key(self): user = self.auths.insert_new_auth( - email="john.doe@openwebui.com", - password="password", - name="John Doe", - profile_image_url="/user.png", - role="admin", + email='john.doe@openwebui.com', + password='password', + name='John Doe', + profile_image_url='/user.png', + role='admin', ) - self.users.update_user_api_key_by_id(user.id, "abc") + self.users.update_user_api_key_by_id(user.id, 'abc') with mock_webui_user(id=user.id): - response = self.fast_api_client.get(self.create_url("/api_key")) + response = self.fast_api_client.get(self.create_url('/api_key')) assert response.status_code == 200 - assert response.json() == {"api_key": "abc"} + assert response.json() == {'api_key': 'abc'} diff --git a/backend/open_webui/test/apps/webui/routers/test_models.py b/backend/open_webui/test/apps/webui/routers/test_models.py index c16ca9d073..6a7e26a519 100644 --- a/backend/open_webui/test/apps/webui/routers/test_models.py +++ b/backend/open_webui/test/apps/webui/routers/test_models.py @@ -3,7 +3,7 @@ class TestModels(AbstractPostgresTest): - BASE_PATH = "/api/v1/models" + BASE_PATH = '/api/v1/models' def setup_class(cls): super().setup_class() @@ -12,50 +12,46 @@ def setup_class(cls): cls.models = Model def test_models(self): - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/")) + with mock_webui_user(id='2'): + response = self.fast_api_client.get(self.create_url('/')) assert response.status_code == 200 assert len(response.json()) == 0 - with mock_webui_user(id="2"): + with mock_webui_user(id='2'): response = self.fast_api_client.post( - self.create_url("/add"), + self.create_url('/add'), json={ - "id": "my-model", - "base_model_id": "base-model-id", - "name": "Hello World", - "meta": { - "profile_image_url": "/static/favicon.png", - "description": "description", - "capabilities": None, - "model_config": {}, + 'id': 'my-model', + 'base_model_id': 'base-model-id', + 'name': 'Hello World', + 'meta': { + 'profile_image_url': '/static/favicon.png', + 'description': 'description', + 'capabilities': None, + 'model_config': {}, }, - "params": {}, + 'params': {}, }, ) assert response.status_code == 200 - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/")) + with mock_webui_user(id='2'): + response = self.fast_api_client.get(self.create_url('/')) assert response.status_code == 200 assert len(response.json()) == 1 - with mock_webui_user(id="2"): - response = self.fast_api_client.get( - self.create_url(query_params={"id": "my-model"}) - ) + with mock_webui_user(id='2'): + response = self.fast_api_client.get(self.create_url(query_params={'id': 'my-model'})) assert response.status_code == 200 data = response.json()[0] - assert data["id"] == "my-model" - assert data["name"] == "Hello World" + assert data['id'] == 'my-model' + assert data['name'] == 'Hello World' - with mock_webui_user(id="2"): - response = self.fast_api_client.delete( - self.create_url("/delete?id=my-model") - ) + with mock_webui_user(id='2'): + response = self.fast_api_client.delete(self.create_url('/delete?id=my-model')) assert response.status_code == 200 - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/")) + with mock_webui_user(id='2'): + response = self.fast_api_client.get(self.create_url('/')) assert response.status_code == 200 assert len(response.json()) == 0 diff --git a/backend/open_webui/test/apps/webui/routers/test_users.py b/backend/open_webui/test/apps/webui/routers/test_users.py index 3108729710..ad64df3508 100644 --- a/backend/open_webui/test/apps/webui/routers/test_users.py +++ b/backend/open_webui/test/apps/webui/routers/test_users.py @@ -3,17 +3,17 @@ def _get_user_by_id(data, param): - return next((item for item in data if item["id"] == param), None) + return next((item for item in data if item['id'] == param), None) def _assert_user(data, id, **kwargs): user = _get_user_by_id(data, id) assert user is not None comparison_data = { - "name": f"user {id}", - "email": f"user{id}@openwebui.com", - "profile_image_url": f"/api/v1/users/{id}/profile/image", - "role": "user", + 'name': f'user {id}', + 'email': f'user{id}@openwebui.com', + 'profile_image_url': f'/api/v1/users/{id}/profile/image', + 'role': 'user', **kwargs, } for key, value in comparison_data.items(): @@ -21,7 +21,7 @@ def _assert_user(data, id, **kwargs): class TestUsers(AbstractPostgresTest): - BASE_PATH = "/api/v1/users" + BASE_PATH = '/api/v1/users' def setup_class(cls): super().setup_class() @@ -32,136 +32,134 @@ def setup_class(cls): def setup_method(self): super().setup_method() self.users.insert_new_user( - id="1", - name="user 1", - email="user1@openwebui.com", - profile_image_url="/user1.png", - role="user", + id='1', + name='user 1', + email='user1@openwebui.com', + profile_image_url='/user1.png', + role='user', ) self.users.insert_new_user( - id="2", - name="user 2", - email="user2@openwebui.com", - profile_image_url="/user2.png", - role="user", + id='2', + name='user 2', + email='user2@openwebui.com', + profile_image_url='/user2.png', + role='user', ) def test_users(self): # Get all users - with mock_webui_user(id="3"): - response = self.fast_api_client.get(self.create_url("")) + with mock_webui_user(id='3'): + response = self.fast_api_client.get(self.create_url('')) assert response.status_code == 200 assert len(response.json()) == 2 data = response.json() - _assert_user(data, "1") - _assert_user(data, "2") + _assert_user(data, '1') + _assert_user(data, '2') # update role - with mock_webui_user(id="3"): - response = self.fast_api_client.post( - self.create_url("/update/role"), json={"id": "2", "role": "admin"} - ) + with mock_webui_user(id='3'): + response = self.fast_api_client.post(self.create_url('/update/role'), json={'id': '2', 'role': 'admin'}) assert response.status_code == 200 - _assert_user([response.json()], "2", role="admin") + _assert_user([response.json()], '2', role='admin') # Get all users - with mock_webui_user(id="3"): - response = self.fast_api_client.get(self.create_url("")) + with mock_webui_user(id='3'): + response = self.fast_api_client.get(self.create_url('')) assert response.status_code == 200 assert len(response.json()) == 2 data = response.json() - _assert_user(data, "1") - _assert_user(data, "2", role="admin") + _assert_user(data, '1') + _assert_user(data, '2', role='admin') # Get (empty) user settings - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/user/settings")) + with mock_webui_user(id='2'): + response = self.fast_api_client.get(self.create_url('/user/settings')) assert response.status_code == 200 assert response.json() is None # Update user settings - with mock_webui_user(id="2"): + with mock_webui_user(id='2'): response = self.fast_api_client.post( - self.create_url("/user/settings/update"), + self.create_url('/user/settings/update'), json={ - "ui": {"attr1": "value1", "attr2": "value2"}, - "model_config": {"attr3": "value3", "attr4": "value4"}, + 'ui': {'attr1': 'value1', 'attr2': 'value2'}, + 'model_config': {'attr3': 'value3', 'attr4': 'value4'}, }, ) assert response.status_code == 200 # Get user settings - with mock_webui_user(id="2"): - response = self.fast_api_client.get(self.create_url("/user/settings")) + with mock_webui_user(id='2'): + response = self.fast_api_client.get(self.create_url('/user/settings')) assert response.status_code == 200 assert response.json() == { - "ui": {"attr1": "value1", "attr2": "value2"}, - "model_config": {"attr3": "value3", "attr4": "value4"}, + 'ui': {'attr1': 'value1', 'attr2': 'value2'}, + 'model_config': {'attr3': 'value3', 'attr4': 'value4'}, } # Get (empty) user info - with mock_webui_user(id="1"): - response = self.fast_api_client.get(self.create_url("/user/info")) + with mock_webui_user(id='1'): + response = self.fast_api_client.get(self.create_url('/user/info')) assert response.status_code == 200 assert response.json() is None # Update user info - with mock_webui_user(id="1"): + with mock_webui_user(id='1'): response = self.fast_api_client.post( - self.create_url("/user/info/update"), - json={"attr1": "value1", "attr2": "value2"}, + self.create_url('/user/info/update'), + json={'attr1': 'value1', 'attr2': 'value2'}, ) assert response.status_code == 200 # Get user info - with mock_webui_user(id="1"): - response = self.fast_api_client.get(self.create_url("/user/info")) + with mock_webui_user(id='1'): + response = self.fast_api_client.get(self.create_url('/user/info')) assert response.status_code == 200 - assert response.json() == {"attr1": "value1", "attr2": "value2"} + assert response.json() == {'attr1': 'value1', 'attr2': 'value2'} # Get user by id - with mock_webui_user(id="1"): - response = self.fast_api_client.get(self.create_url("/2")) + with mock_webui_user(id='1'): + response = self.fast_api_client.get(self.create_url('/2')) assert response.status_code == 200 - assert response.json() == {"name": "user 2", "profile_image_url": "/user2.png"} + assert response.json() == {'name': 'user 2', 'profile_image_url': '/user2.png'} # Update user by id - with mock_webui_user(id="1"): + with mock_webui_user(id='1'): response = self.fast_api_client.post( - self.create_url("/2/update"), + self.create_url('/2/update'), json={ - "name": "user 2 updated", - "email": "user2-updated@openwebui.com", - "profile_image_url": "/user2-updated.png", + 'name': 'user 2 updated', + 'email': 'user2-updated@openwebui.com', + 'profile_image_url': '/user2-updated.png', }, ) assert response.status_code == 200 # Get all users - with mock_webui_user(id="3"): - response = self.fast_api_client.get(self.create_url("")) + with mock_webui_user(id='3'): + response = self.fast_api_client.get(self.create_url('')) assert response.status_code == 200 assert len(response.json()) == 2 data = response.json() - _assert_user(data, "1") + _assert_user(data, '1') _assert_user( data, - "2", - role="admin", - name="user 2 updated", - email="user2-updated@openwebui.com", - profile_image_url=f"/api/v1/users/2/profile/image", + '2', + role='admin', + name='user 2 updated', + email='user2-updated@openwebui.com', + profile_image_url=f'/api/v1/users/2/profile/image', ) # Delete user by id - with mock_webui_user(id="1"): - response = self.fast_api_client.delete(self.create_url("/2")) + with mock_webui_user(id='1'): + response = self.fast_api_client.delete(self.create_url('/2')) assert response.status_code == 200 # Get all users - with mock_webui_user(id="3"): - response = self.fast_api_client.get(self.create_url("")) + with mock_webui_user(id='3'): + response = self.fast_api_client.get(self.create_url('')) assert response.status_code == 200 assert len(response.json()) == 1 data = response.json() - _assert_user(data, "1") + _assert_user(data, '1') diff --git a/backend/open_webui/test/apps/webui/storage/test_provider.py b/backend/open_webui/test/apps/webui/storage/test_provider.py index 3c874592fe..806b072d87 100644 --- a/backend/open_webui/test/apps/webui/storage/test_provider.py +++ b/backend/open_webui/test/apps/webui/storage/test_provider.py @@ -13,9 +13,9 @@ def mock_upload_dir(monkeypatch, tmp_path): """Fixture to monkey-patch the UPLOAD_DIR and create a temporary directory.""" - directory = tmp_path / "uploads" + directory = tmp_path / 'uploads' directory.mkdir() - monkeypatch.setattr(provider, "UPLOAD_DIR", str(directory)) + monkeypatch.setattr(provider, 'UPLOAD_DIR', str(directory)) return directory @@ -29,16 +29,16 @@ def test_imports(): def test_get_storage_provider(): - Storage = provider.get_storage_provider("local") + Storage = provider.get_storage_provider('local') assert isinstance(Storage, provider.LocalStorageProvider) - Storage = provider.get_storage_provider("s3") + Storage = provider.get_storage_provider('s3') assert isinstance(Storage, provider.S3StorageProvider) - Storage = provider.get_storage_provider("gcs") + Storage = provider.get_storage_provider('gcs') assert isinstance(Storage, provider.GCSStorageProvider) - Storage = provider.get_storage_provider("azure") + Storage = provider.get_storage_provider('azure') assert isinstance(Storage, provider.AzureStorageProvider) with pytest.raises(RuntimeError): - provider.get_storage_provider("invalid") + provider.get_storage_provider('invalid') def test_class_instantiation(): @@ -58,10 +58,10 @@ class Test(provider.StorageProvider): class TestLocalStorageProvider: Storage = provider.LocalStorageProvider() - file_content = b"test content" + file_content = b'test content' file_bytesio = io.BytesIO(file_content) - filename = "test.txt" - filename_extra = "test_exyta.txt" + filename = 'test.txt' + filename_extra = 'test_exyta.txt' file_bytesio_empty = io.BytesIO() def test_upload_file(self, monkeypatch, tmp_path): @@ -99,14 +99,13 @@ def test_delete_all_files(self, monkeypatch, tmp_path): @mock_aws class TestS3StorageProvider: - def __init__(self): self.Storage = provider.S3StorageProvider() - self.Storage.bucket_name = "my-bucket" - self.s3_client = boto3.resource("s3", region_name="us-east-1") - self.file_content = b"test content" - self.filename = "test.txt" - self.filename_extra = "test_exyta.txt" + self.Storage.bucket_name = 'my-bucket' + self.s3_client = boto3.resource('s3', region_name='us-east-1') + self.file_content = b'test content' + self.filename = 'test.txt' + self.filename_extra = 'test_exyta.txt' self.file_bytesio_empty = io.BytesIO() super().__init__() @@ -116,25 +115,21 @@ def test_upload_file(self, monkeypatch, tmp_path): with pytest.raises(Exception): self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) self.s3_client.create_bucket(Bucket=self.Storage.bucket_name) - contents, s3_file_path = self.Storage.upload_file( - io.BytesIO(self.file_content), self.filename - ) + contents, s3_file_path = self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) object = self.s3_client.Object(self.Storage.bucket_name, self.filename) - assert self.file_content == object.get()["Body"].read() + assert self.file_content == object.get()['Body'].read() # local checks assert (upload_dir / self.filename).exists() assert (upload_dir / self.filename).read_bytes() == self.file_content assert contents == self.file_content - assert s3_file_path == "s3://" + self.Storage.bucket_name + "/" + self.filename + assert s3_file_path == 's3://' + self.Storage.bucket_name + '/' + self.filename with pytest.raises(ValueError): self.Storage.upload_file(self.file_bytesio_empty, self.filename) def test_get_file(self, monkeypatch, tmp_path): upload_dir = mock_upload_dir(monkeypatch, tmp_path) self.s3_client.create_bucket(Bucket=self.Storage.bucket_name) - contents, s3_file_path = self.Storage.upload_file( - io.BytesIO(self.file_content), self.filename - ) + contents, s3_file_path = self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) file_path = self.Storage.get_file(s3_file_path) assert file_path == str(upload_dir / self.filename) assert (upload_dir / self.filename).exists() @@ -142,17 +137,15 @@ def test_get_file(self, monkeypatch, tmp_path): def test_delete_file(self, monkeypatch, tmp_path): upload_dir = mock_upload_dir(monkeypatch, tmp_path) self.s3_client.create_bucket(Bucket=self.Storage.bucket_name) - contents, s3_file_path = self.Storage.upload_file( - io.BytesIO(self.file_content), self.filename - ) + contents, s3_file_path = self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) assert (upload_dir / self.filename).exists() self.Storage.delete_file(s3_file_path) assert not (upload_dir / self.filename).exists() with pytest.raises(ClientError) as exc: self.s3_client.Object(self.Storage.bucket_name, self.filename).load() - error = exc.value.response["Error"] - assert error["Code"] == "404" - assert error["Message"] == "Not Found" + error = exc.value.response['Error'] + assert error['Code'] == '404' + assert error['Message'] == 'Not Found' def test_delete_all_files(self, monkeypatch, tmp_path): upload_dir = mock_upload_dir(monkeypatch, tmp_path) @@ -160,12 +153,12 @@ def test_delete_all_files(self, monkeypatch, tmp_path): self.s3_client.create_bucket(Bucket=self.Storage.bucket_name) self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) object = self.s3_client.Object(self.Storage.bucket_name, self.filename) - assert self.file_content == object.get()["Body"].read() + assert self.file_content == object.get()['Body'].read() assert (upload_dir / self.filename).exists() assert (upload_dir / self.filename).read_bytes() == self.file_content self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra) object = self.s3_client.Object(self.Storage.bucket_name, self.filename_extra) - assert self.file_content == object.get()["Body"].read() + assert self.file_content == object.get()['Body'].read() assert (upload_dir / self.filename).exists() assert (upload_dir / self.filename).read_bytes() == self.file_content @@ -173,15 +166,15 @@ def test_delete_all_files(self, monkeypatch, tmp_path): assert not (upload_dir / self.filename).exists() with pytest.raises(ClientError) as exc: self.s3_client.Object(self.Storage.bucket_name, self.filename).load() - error = exc.value.response["Error"] - assert error["Code"] == "404" - assert error["Message"] == "Not Found" + error = exc.value.response['Error'] + assert error['Code'] == '404' + assert error['Message'] == 'Not Found' assert not (upload_dir / self.filename_extra).exists() with pytest.raises(ClientError) as exc: self.s3_client.Object(self.Storage.bucket_name, self.filename_extra).load() - error = exc.value.response["Error"] - assert error["Code"] == "404" - assert error["Message"] == "Not Found" + error = exc.value.response['Error'] + assert error['Code'] == '404' + assert error['Message'] == 'Not Found' self.Storage.delete_all_files() assert not (upload_dir / self.filename).exists() @@ -190,8 +183,8 @@ def test_delete_all_files(self, monkeypatch, tmp_path): def test_init_without_credentials(self, monkeypatch): """Test that S3StorageProvider can initialize without explicit credentials.""" # Temporarily unset the environment variables - monkeypatch.setattr(provider, "S3_ACCESS_KEY_ID", None) - monkeypatch.setattr(provider, "S3_SECRET_ACCESS_KEY", None) + monkeypatch.setattr(provider, 'S3_ACCESS_KEY_ID', None) + monkeypatch.setattr(provider, 'S3_SECRET_ACCESS_KEY', None) # Should not raise an exception storage = provider.S3StorageProvider() @@ -201,19 +194,19 @@ def test_init_without_credentials(self, monkeypatch): class TestGCSStorageProvider: Storage = provider.GCSStorageProvider() - Storage.bucket_name = "my-bucket" - file_content = b"test content" - filename = "test.txt" - filename_extra = "test_exyta.txt" + Storage.bucket_name = 'my-bucket' + file_content = b'test content' + filename = 'test.txt' + filename_extra = 'test_exyta.txt' file_bytesio_empty = io.BytesIO() - @pytest.fixture(scope="class") + @pytest.fixture(scope='class') def setup(self): - host, port = "localhost", 9023 + host, port = 'localhost', 9023 server = create_server(host, port, in_memory=True) server.start() - os.environ["STORAGE_EMULATOR_HOST"] = f"http://{host}:{port}" + os.environ['STORAGE_EMULATOR_HOST'] = f'http://{host}:{port}' gcs_client = storage.Client() bucket = gcs_client.bucket(self.Storage.bucket_name) @@ -227,36 +220,30 @@ def test_upload_file(self, monkeypatch, tmp_path, setup): upload_dir = mock_upload_dir(monkeypatch, tmp_path) # catch error if bucket does not exist with pytest.raises(Exception): - self.Storage.bucket = monkeypatch(self.Storage, "bucket", None) + self.Storage.bucket = monkeypatch(self.Storage, 'bucket', None) self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) - contents, gcs_file_path = self.Storage.upload_file( - io.BytesIO(self.file_content), self.filename - ) + contents, gcs_file_path = self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) object = self.Storage.bucket.get_blob(self.filename) assert self.file_content == object.download_as_bytes() # local checks assert (upload_dir / self.filename).exists() assert (upload_dir / self.filename).read_bytes() == self.file_content assert contents == self.file_content - assert gcs_file_path == "gs://" + self.Storage.bucket_name + "/" + self.filename + assert gcs_file_path == 'gs://' + self.Storage.bucket_name + '/' + self.filename # test error if file is empty with pytest.raises(ValueError): self.Storage.upload_file(self.file_bytesio_empty, self.filename) def test_get_file(self, monkeypatch, tmp_path, setup): upload_dir = mock_upload_dir(monkeypatch, tmp_path) - contents, gcs_file_path = self.Storage.upload_file( - io.BytesIO(self.file_content), self.filename - ) + contents, gcs_file_path = self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) file_path = self.Storage.get_file(gcs_file_path) assert file_path == str(upload_dir / self.filename) assert (upload_dir / self.filename).exists() def test_delete_file(self, monkeypatch, tmp_path, setup): upload_dir = mock_upload_dir(monkeypatch, tmp_path) - contents, gcs_file_path = self.Storage.upload_file( - io.BytesIO(self.file_content), self.filename - ) + contents, gcs_file_path = self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) # ensure that local directory has the uploaded file as well assert (upload_dir / self.filename).exists() assert self.Storage.bucket.get_blob(self.filename).name == self.filename @@ -278,10 +265,7 @@ def test_delete_all_files(self, monkeypatch, tmp_path, setup): object = self.Storage.bucket.get_blob(self.filename_extra) assert (upload_dir / self.filename_extra).exists() assert (upload_dir / self.filename_extra).read_bytes() == self.file_content - assert ( - self.Storage.bucket.get_blob(self.filename_extra).name - == self.filename_extra - ) + assert self.Storage.bucket.get_blob(self.filename_extra).name == self.filename_extra assert self.file_content == object.download_as_bytes() self.Storage.delete_all_files() @@ -295,7 +279,7 @@ class TestAzureStorageProvider: def __init__(self): super().__init__() - @pytest.fixture(scope="class") + @pytest.fixture(scope='class') def setup_storage(self, monkeypatch): # Create mock Blob Service Client and related clients mock_blob_service_client = MagicMock() @@ -303,32 +287,28 @@ def setup_storage(self, monkeypatch): mock_blob_client = MagicMock() # Set up return values for the mock - mock_blob_service_client.get_container_client.return_value = ( - mock_container_client - ) + mock_blob_service_client.get_container_client.return_value = mock_container_client mock_container_client.get_blob_client.return_value = mock_blob_client # Monkeypatch the Azure classes to return our mocks monkeypatch.setattr( azure.storage.blob, - "BlobServiceClient", + 'BlobServiceClient', lambda *args, **kwargs: mock_blob_service_client, ) monkeypatch.setattr( azure.storage.blob, - "ContainerClient", + 'ContainerClient', lambda *args, **kwargs: mock_container_client, ) - monkeypatch.setattr( - azure.storage.blob, "BlobClient", lambda *args, **kwargs: mock_blob_client - ) + monkeypatch.setattr(azure.storage.blob, 'BlobClient', lambda *args, **kwargs: mock_blob_client) self.Storage = provider.AzureStorageProvider() - self.Storage.endpoint = "https://myaccount.blob.core.windows.net" - self.Storage.container_name = "my-container" - self.file_content = b"test content" - self.filename = "test.txt" - self.filename_extra = "test_extra.txt" + self.Storage.endpoint = 'https://myaccount.blob.core.windows.net' + self.Storage.container_name = 'my-container' + self.file_content = b'test content' + self.filename = 'test.txt' + self.filename_extra = 'test_extra.txt' self.file_bytesio_empty = io.BytesIO() # Apply mocks to the Storage instance @@ -339,18 +319,14 @@ def test_upload_file(self, monkeypatch, tmp_path): upload_dir = mock_upload_dir(monkeypatch, tmp_path) # Simulate an error when container does not exist - self.Storage.container_client.get_blob_client.side_effect = Exception( - "Container does not exist" - ) + self.Storage.container_client.get_blob_client.side_effect = Exception('Container does not exist') with pytest.raises(Exception): self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) # Reset side effect and create container self.Storage.container_client.get_blob_client.side_effect = None self.Storage.create_container() - contents, azure_file_path = self.Storage.upload_file( - io.BytesIO(self.file_content), self.filename - ) + contents, azure_file_path = self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) # Assertions self.Storage.container_client.get_blob_client.assert_called_with(self.filename) @@ -359,8 +335,7 @@ def test_upload_file(self, monkeypatch, tmp_path): ) assert contents == self.file_content assert ( - azure_file_path - == f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}" + azure_file_path == f'https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}' ) assert (upload_dir / self.filename).exists() assert (upload_dir / self.filename).read_bytes() == self.file_content @@ -375,11 +350,9 @@ def test_get_file(self, monkeypatch, tmp_path): # Mock upload behavior self.Storage.upload_file(io.BytesIO(self.file_content), self.filename) # Mock blob download behavior - self.Storage.container_client.get_blob_client().download_blob().readall.return_value = ( - self.file_content - ) + self.Storage.container_client.get_blob_client().download_blob().readall.return_value = self.file_content - file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}" + file_url = f'https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}' file_path = self.Storage.get_file(file_url) assert file_path == str(upload_dir / self.filename) @@ -395,7 +368,7 @@ def test_delete_file(self, monkeypatch, tmp_path): # Mock deletion self.Storage.container_client.get_blob_client().delete_blob.return_value = None - file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}" + file_url = f'https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}' self.Storage.delete_file(file_url) self.Storage.container_client.get_blob_client().delete_blob.assert_called_once() @@ -411,8 +384,8 @@ def test_delete_all_files(self, monkeypatch, tmp_path): # Mock listing and deletion behavior self.Storage.container_client.list_blobs.return_value = [ - {"name": self.filename}, - {"name": self.filename_extra}, + {'name': self.filename}, + {'name': self.filename_extra}, ] self.Storage.container_client.get_blob_client().delete_blob.return_value = None @@ -426,10 +399,8 @@ def test_delete_all_files(self, monkeypatch, tmp_path): def test_get_file_not_found(self, monkeypatch): self.Storage.create_container() - file_url = f"https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}" + file_url = f'https://myaccount.blob.core.windows.net/{self.Storage.container_name}/{self.filename}' # Mock behavior to raise an error for missing blobs - self.Storage.container_client.get_blob_client().download_blob.side_effect = ( - Exception("Blob not found") - ) - with pytest.raises(Exception, match="Blob not found"): + self.Storage.container_client.get_blob_client().download_blob.side_effect = Exception('Blob not found') + with pytest.raises(Exception, match='Blob not found'): self.Storage.get_file(file_url) diff --git a/backend/open_webui/test/util/test_redis.py b/backend/open_webui/test/util/test_redis.py index 8c393ce9d9..036fb36362 100644 --- a/backend/open_webui/test/util/test_redis.py +++ b/backend/open_webui/test/util/test_redis.py @@ -16,84 +16,84 @@ class TestSentinelRedisProxy: def test_parse_redis_service_url_valid(self): """Test parsing valid Redis service URL""" - url = "redis://user:pass@mymaster:6379/0" + url = 'redis://user:pass@mymaster:6379/0' result = parse_redis_service_url(url) - assert result["username"] == "user" - assert result["password"] == "pass" - assert result["service"] == "mymaster" - assert result["port"] == 6379 - assert result["db"] == 0 + assert result['username'] == 'user' + assert result['password'] == 'pass' + assert result['service'] == 'mymaster' + assert result['port'] == 6379 + assert result['db'] == 0 def test_parse_redis_service_url_defaults(self): """Test parsing Redis service URL with defaults""" - url = "redis://mymaster" + url = 'redis://mymaster' result = parse_redis_service_url(url) - assert result["username"] is None - assert result["password"] is None - assert result["service"] == "mymaster" - assert result["port"] == 6379 - assert result["db"] == 0 + assert result['username'] is None + assert result['password'] is None + assert result['service'] == 'mymaster' + assert result['port'] == 6379 + assert result['db'] == 0 def test_parse_redis_service_url_invalid_scheme(self): """Test parsing invalid URL scheme""" - with pytest.raises(ValueError, match="Invalid Redis URL scheme"): - parse_redis_service_url("http://invalid") + with pytest.raises(ValueError, match='Invalid Redis URL scheme'): + parse_redis_service_url('http://invalid') def test_get_sentinels_from_env(self): """Test parsing sentinel hosts from environment""" - hosts = "sentinel1,sentinel2,sentinel3" - port = "26379" + hosts = 'sentinel1,sentinel2,sentinel3' + port = '26379' result = get_sentinels_from_env(hosts, port) - expected = [("sentinel1", 26379), ("sentinel2", 26379), ("sentinel3", 26379)] + expected = [('sentinel1', 26379), ('sentinel2', 26379), ('sentinel3', 26379)] assert result == expected def test_get_sentinels_from_env_empty(self): """Test empty sentinel hosts""" - result = get_sentinels_from_env(None, "26379") + result = get_sentinels_from_env(None, '26379') assert result == [] - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_sentinel_redis_proxy_sync_success(self, mock_sentinel_class): """Test successful sync operation with SentinelRedisProxy""" mock_sentinel = Mock() mock_master = Mock() - mock_master.get.return_value = "test_value" + mock_master.get.return_value = 'test_value' mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Test attribute access - get_method = proxy.__getattr__("get") - result = get_method("test_key") + get_method = proxy.__getattr__('get') + result = get_method('test_key') - assert result == "test_value" - mock_sentinel.master_for.assert_called_with("mymaster") - mock_master.get.assert_called_with("test_key") + assert result == 'test_value' + mock_sentinel.master_for.assert_called_with('mymaster') + mock_master.get.assert_called_with('test_key') - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_sentinel_redis_proxy_async_success(self, mock_sentinel_class): """Test successful async operation with SentinelRedisProxy""" mock_sentinel = Mock() mock_master = Mock() - mock_master.get = AsyncMock(return_value="test_value") + mock_master.get = AsyncMock(return_value='test_value') mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test async attribute access - get_method = proxy.__getattr__("get") - result = await get_method("test_key") + get_method = proxy.__getattr__('get') + result = await get_method('test_key') - assert result == "test_value" - mock_sentinel.master_for.assert_called_with("mymaster") - mock_master.get.assert_called_with("test_key") + assert result == 'test_value' + mock_sentinel.master_for.assert_called_with('mymaster') + mock_master.get.assert_called_with('test_key') - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_sentinel_redis_proxy_failover_retry(self, mock_sentinel_class): """Test retry mechanism during failover""" mock_sentinel = Mock() @@ -101,39 +101,39 @@ def test_sentinel_redis_proxy_failover_retry(self, mock_sentinel_class): # First call fails, second succeeds mock_master.get.side_effect = [ - redis.exceptions.ConnectionError("Master down"), - "test_value", + redis.exceptions.ConnectionError('Master down'), + 'test_value', ] mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) - get_method = proxy.__getattr__("get") - result = get_method("test_key") + get_method = proxy.__getattr__('get') + result = get_method('test_key') - assert result == "test_value" + assert result == 'test_value' assert mock_master.get.call_count == 2 - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_sentinel_redis_proxy_max_retries_exceeded(self, mock_sentinel_class): """Test failure after max retries exceeded""" mock_sentinel = Mock() mock_master = Mock() # All calls fail - mock_master.get.side_effect = redis.exceptions.ConnectionError("Master down") + mock_master.get.side_effect = redis.exceptions.ConnectionError('Master down') mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) - get_method = proxy.__getattr__("get") + get_method = proxy.__getattr__('get') with pytest.raises(redis.exceptions.ConnectionError): - get_method("test_key") + get_method('test_key') assert mock_master.get.call_count == MAX_RETRY_COUNT - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_sentinel_redis_proxy_readonly_error_retry(self, mock_sentinel_class): """Test retry on ReadOnlyError""" mock_sentinel = Mock() @@ -141,20 +141,20 @@ def test_sentinel_redis_proxy_readonly_error_retry(self, mock_sentinel_class): # First call gets ReadOnlyError (old master), second succeeds (new master) mock_master.get.side_effect = [ - redis.exceptions.ReadOnlyError("Read only"), - "test_value", + redis.exceptions.ReadOnlyError('Read only'), + 'test_value', ] mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) - get_method = proxy.__getattr__("get") - result = get_method("test_key") + get_method = proxy.__getattr__('get') + result = get_method('test_key') - assert result == "test_value" + assert result == 'test_value' assert mock_master.get.call_count == 2 - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_sentinel_redis_proxy_factory_methods(self, mock_sentinel_class): """Test factory methods are passed through directly""" mock_sentinel = Mock() @@ -163,61 +163,53 @@ def test_sentinel_redis_proxy_factory_methods(self, mock_sentinel_class): mock_master.pipeline.return_value = mock_pipeline mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Factory methods should be passed through without wrapping - pipeline_method = proxy.__getattr__("pipeline") + pipeline_method = proxy.__getattr__('pipeline') result = pipeline_method() assert result == mock_pipeline mock_master.pipeline.assert_called_once() - @patch("redis.sentinel.Sentinel") - @patch("redis.from_url") - def test_get_redis_connection_with_sentinel( - self, mock_from_url, mock_sentinel_class - ): + @patch('redis.sentinel.Sentinel') + @patch('redis.from_url') + def test_get_redis_connection_with_sentinel(self, mock_from_url, mock_sentinel_class): """Test getting Redis connection with Sentinel""" mock_sentinel = Mock() mock_sentinel_class.return_value = mock_sentinel - sentinels = [("sentinel1", 26379), ("sentinel2", 26379)] - redis_url = "redis://user:pass@mymaster:6379/0" + sentinels = [('sentinel1', 26379), ('sentinel2', 26379)] + redis_url = 'redis://user:pass@mymaster:6379/0' - result = get_redis_connection( - redis_url=redis_url, redis_sentinels=sentinels, async_mode=False - ) + result = get_redis_connection(redis_url=redis_url, redis_sentinels=sentinels, async_mode=False) assert isinstance(result, SentinelRedisProxy) mock_sentinel_class.assert_called_once() mock_from_url.assert_not_called() - @patch("redis.Redis.from_url") + @patch('redis.Redis.from_url') def test_get_redis_connection_without_sentinel(self, mock_from_url): """Test getting Redis connection without Sentinel""" mock_redis = Mock() mock_from_url.return_value = mock_redis - redis_url = "redis://localhost:6379/0" + redis_url = 'redis://localhost:6379/0' - result = get_redis_connection( - redis_url=redis_url, redis_sentinels=None, async_mode=False - ) + result = get_redis_connection(redis_url=redis_url, redis_sentinels=None, async_mode=False) assert result == mock_redis mock_from_url.assert_called_once_with(redis_url, decode_responses=True) - @patch("redis.asyncio.from_url") + @patch('redis.asyncio.from_url') def test_get_redis_connection_without_sentinel_async(self, mock_from_url): """Test getting async Redis connection without Sentinel""" mock_redis = Mock() mock_from_url.return_value = mock_redis - redis_url = "redis://localhost:6379/0" + redis_url = 'redis://localhost:6379/0' - result = get_redis_connection( - redis_url=redis_url, redis_sentinels=None, async_mode=True - ) + result = get_redis_connection(redis_url=redis_url, redis_sentinels=None, async_mode=True) assert result == mock_redis mock_from_url.assert_called_once_with(redis_url, decode_responses=True) @@ -226,7 +218,7 @@ def test_get_redis_connection_without_sentinel_async(self, mock_from_url): class TestSentinelRedisProxyCommands: """Test Redis commands through SentinelRedisProxy""" - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_hash_commands_sync(self, mock_sentinel_class): """Test Redis hash commands in sync mode""" mock_sentinel = Mock() @@ -234,39 +226,39 @@ def test_hash_commands_sync(self, mock_sentinel_class): # Mock hash command responses mock_master.hset.return_value = 1 - mock_master.hget.return_value = "test_value" - mock_master.hgetall.return_value = {"key1": "value1", "key2": "value2"} + mock_master.hget.return_value = 'test_value' + mock_master.hgetall.return_value = {'key1': 'value1', 'key2': 'value2'} mock_master.hdel.return_value = 1 mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Test hset - hset_method = proxy.__getattr__("hset") - result = hset_method("test_hash", "field1", "value1") + hset_method = proxy.__getattr__('hset') + result = hset_method('test_hash', 'field1', 'value1') assert result == 1 - mock_master.hset.assert_called_with("test_hash", "field1", "value1") + mock_master.hset.assert_called_with('test_hash', 'field1', 'value1') # Test hget - hget_method = proxy.__getattr__("hget") - result = hget_method("test_hash", "field1") - assert result == "test_value" - mock_master.hget.assert_called_with("test_hash", "field1") + hget_method = proxy.__getattr__('hget') + result = hget_method('test_hash', 'field1') + assert result == 'test_value' + mock_master.hget.assert_called_with('test_hash', 'field1') # Test hgetall - hgetall_method = proxy.__getattr__("hgetall") - result = hgetall_method("test_hash") - assert result == {"key1": "value1", "key2": "value2"} - mock_master.hgetall.assert_called_with("test_hash") + hgetall_method = proxy.__getattr__('hgetall') + result = hgetall_method('test_hash') + assert result == {'key1': 'value1', 'key2': 'value2'} + mock_master.hgetall.assert_called_with('test_hash') # Test hdel - hdel_method = proxy.__getattr__("hdel") - result = hdel_method("test_hash", "field1") + hdel_method = proxy.__getattr__('hdel') + result = hdel_method('test_hash', 'field1') assert result == 1 - mock_master.hdel.assert_called_with("test_hash", "field1") + mock_master.hdel.assert_called_with('test_hash', 'field1') - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_hash_commands_async(self, mock_sentinel_class): """Test Redis hash commands in async mode""" @@ -275,34 +267,32 @@ async def test_hash_commands_async(self, mock_sentinel_class): # Mock async hash command responses mock_master.hset = AsyncMock(return_value=1) - mock_master.hget = AsyncMock(return_value="test_value") - mock_master.hgetall = AsyncMock( - return_value={"key1": "value1", "key2": "value2"} - ) + mock_master.hget = AsyncMock(return_value='test_value') + mock_master.hgetall = AsyncMock(return_value={'key1': 'value1', 'key2': 'value2'}) mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test hset - hset_method = proxy.__getattr__("hset") - result = await hset_method("test_hash", "field1", "value1") + hset_method = proxy.__getattr__('hset') + result = await hset_method('test_hash', 'field1', 'value1') assert result == 1 - mock_master.hset.assert_called_with("test_hash", "field1", "value1") + mock_master.hset.assert_called_with('test_hash', 'field1', 'value1') # Test hget - hget_method = proxy.__getattr__("hget") - result = await hget_method("test_hash", "field1") - assert result == "test_value" - mock_master.hget.assert_called_with("test_hash", "field1") + hget_method = proxy.__getattr__('hget') + result = await hget_method('test_hash', 'field1') + assert result == 'test_value' + mock_master.hget.assert_called_with('test_hash', 'field1') # Test hgetall - hgetall_method = proxy.__getattr__("hgetall") - result = await hgetall_method("test_hash") - assert result == {"key1": "value1", "key2": "value2"} - mock_master.hgetall.assert_called_with("test_hash") + hgetall_method = proxy.__getattr__('hgetall') + result = await hgetall_method('test_hash') + assert result == {'key1': 'value1', 'key2': 'value2'} + mock_master.hgetall.assert_called_with('test_hash') - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_string_commands_sync(self, mock_sentinel_class): """Test Redis string commands in sync mode""" mock_sentinel = Mock() @@ -310,39 +300,39 @@ def test_string_commands_sync(self, mock_sentinel_class): # Mock string command responses mock_master.set.return_value = True - mock_master.get.return_value = "test_value" + mock_master.get.return_value = 'test_value' mock_master.delete.return_value = 1 mock_master.exists.return_value = True mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Test set - set_method = proxy.__getattr__("set") - result = set_method("test_key", "test_value") + set_method = proxy.__getattr__('set') + result = set_method('test_key', 'test_value') assert result is True - mock_master.set.assert_called_with("test_key", "test_value") + mock_master.set.assert_called_with('test_key', 'test_value') # Test get - get_method = proxy.__getattr__("get") - result = get_method("test_key") - assert result == "test_value" - mock_master.get.assert_called_with("test_key") + get_method = proxy.__getattr__('get') + result = get_method('test_key') + assert result == 'test_value' + mock_master.get.assert_called_with('test_key') # Test delete - delete_method = proxy.__getattr__("delete") - result = delete_method("test_key") + delete_method = proxy.__getattr__('delete') + result = delete_method('test_key') assert result == 1 - mock_master.delete.assert_called_with("test_key") + mock_master.delete.assert_called_with('test_key') # Test exists - exists_method = proxy.__getattr__("exists") - result = exists_method("test_key") + exists_method = proxy.__getattr__('exists') + result = exists_method('test_key') assert result is True - mock_master.exists.assert_called_with("test_key") + mock_master.exists.assert_called_with('test_key') - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_list_commands_sync(self, mock_sentinel_class): """Test Redis list commands in sync mode""" mock_sentinel = Mock() @@ -350,39 +340,39 @@ def test_list_commands_sync(self, mock_sentinel_class): # Mock list command responses mock_master.lpush.return_value = 1 - mock_master.rpop.return_value = "test_value" + mock_master.rpop.return_value = 'test_value' mock_master.llen.return_value = 5 - mock_master.lrange.return_value = ["item1", "item2", "item3"] + mock_master.lrange.return_value = ['item1', 'item2', 'item3'] mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Test lpush - lpush_method = proxy.__getattr__("lpush") - result = lpush_method("test_list", "item1") + lpush_method = proxy.__getattr__('lpush') + result = lpush_method('test_list', 'item1') assert result == 1 - mock_master.lpush.assert_called_with("test_list", "item1") + mock_master.lpush.assert_called_with('test_list', 'item1') # Test rpop - rpop_method = proxy.__getattr__("rpop") - result = rpop_method("test_list") - assert result == "test_value" - mock_master.rpop.assert_called_with("test_list") + rpop_method = proxy.__getattr__('rpop') + result = rpop_method('test_list') + assert result == 'test_value' + mock_master.rpop.assert_called_with('test_list') # Test llen - llen_method = proxy.__getattr__("llen") - result = llen_method("test_list") + llen_method = proxy.__getattr__('llen') + result = llen_method('test_list') assert result == 5 - mock_master.llen.assert_called_with("test_list") + mock_master.llen.assert_called_with('test_list') # Test lrange - lrange_method = proxy.__getattr__("lrange") - result = lrange_method("test_list", 0, -1) - assert result == ["item1", "item2", "item3"] - mock_master.lrange.assert_called_with("test_list", 0, -1) + lrange_method = proxy.__getattr__('lrange') + result = lrange_method('test_list', 0, -1) + assert result == ['item1', 'item2', 'item3'] + mock_master.lrange.assert_called_with('test_list', 0, -1) - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_pubsub_commands_sync(self, mock_sentinel_class): """Test Redis pubsub commands in sync mode""" mock_sentinel = Mock() @@ -393,25 +383,25 @@ def test_pubsub_commands_sync(self, mock_sentinel_class): mock_master.pubsub.return_value = mock_pubsub mock_master.publish.return_value = 1 mock_pubsub.subscribe.return_value = None - mock_pubsub.get_message.return_value = {"type": "message", "data": "test_data"} + mock_pubsub.get_message.return_value = {'type': 'message', 'data': 'test_data'} mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Test pubsub (factory method - should pass through) - pubsub_method = proxy.__getattr__("pubsub") + pubsub_method = proxy.__getattr__('pubsub') result = pubsub_method() assert result == mock_pubsub mock_master.pubsub.assert_called_once() # Test publish - publish_method = proxy.__getattr__("publish") - result = publish_method("test_channel", "test_message") + publish_method = proxy.__getattr__('publish') + result = publish_method('test_channel', 'test_message') assert result == 1 - mock_master.publish.assert_called_with("test_channel", "test_message") + mock_master.publish.assert_called_with('test_channel', 'test_message') - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_pipeline_commands_sync(self, mock_sentinel_class): """Test Redis pipeline commands in sync mode""" mock_sentinel = Mock() @@ -422,19 +412,19 @@ def test_pipeline_commands_sync(self, mock_sentinel_class): mock_master.pipeline.return_value = mock_pipeline mock_pipeline.set.return_value = mock_pipeline mock_pipeline.get.return_value = mock_pipeline - mock_pipeline.execute.return_value = [True, "test_value"] + mock_pipeline.execute.return_value = [True, 'test_value'] mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Test pipeline (factory method - should pass through) - pipeline_method = proxy.__getattr__("pipeline") + pipeline_method = proxy.__getattr__('pipeline') result = pipeline_method() assert result == mock_pipeline mock_master.pipeline.assert_called_once() - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_commands_with_failover_retry(self, mock_sentinel_class): """Test Redis commands with failover retry mechanism""" mock_sentinel = Mock() @@ -442,27 +432,27 @@ def test_commands_with_failover_retry(self, mock_sentinel_class): # First call fails with connection error, second succeeds mock_master.hget.side_effect = [ - redis.exceptions.ConnectionError("Connection failed"), - "recovered_value", + redis.exceptions.ConnectionError('Connection failed'), + 'recovered_value', ] mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Test hget with retry - hget_method = proxy.__getattr__("hget") - result = hget_method("test_hash", "field1") + hget_method = proxy.__getattr__('hget') + result = hget_method('test_hash', 'field1') - assert result == "recovered_value" + assert result == 'recovered_value' assert mock_master.hget.call_count == 2 # Verify both calls were made with same parameters - expected_calls = [(("test_hash", "field1"),), (("test_hash", "field1"),)] + expected_calls = [(('test_hash', 'field1'),), (('test_hash', 'field1'),)] actual_calls = [call.args for call in mock_master.hget.call_args_list] assert actual_calls == expected_calls - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') def test_commands_with_readonly_error_retry(self, mock_sentinel_class): """Test Redis commands with ReadOnlyError retry mechanism""" mock_sentinel = Mock() @@ -470,32 +460,30 @@ def test_commands_with_readonly_error_retry(self, mock_sentinel_class): # First call fails with ReadOnlyError, second succeeds mock_master.hset.side_effect = [ - redis.exceptions.ReadOnlyError( - "READONLY You can't write against a read only replica" - ), + redis.exceptions.ReadOnlyError("READONLY You can't write against a read only replica"), 1, ] mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=False) # Test hset with retry - hset_method = proxy.__getattr__("hset") - result = hset_method("test_hash", "field1", "value1") + hset_method = proxy.__getattr__('hset') + result = hset_method('test_hash', 'field1', 'value1') assert result == 1 assert mock_master.hset.call_count == 2 # Verify both calls were made with same parameters expected_calls = [ - (("test_hash", "field1", "value1"),), - (("test_hash", "field1", "value1"),), + (('test_hash', 'field1', 'value1'),), + (('test_hash', 'field1', 'value1'),), ] actual_calls = [call.args for call in mock_master.hset.call_args_list] assert actual_calls == expected_calls - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_async_commands_with_failover_retry(self, mock_sentinel_class): """Test async Redis commands with failover retry mechanism""" @@ -505,24 +493,24 @@ async def test_async_commands_with_failover_retry(self, mock_sentinel_class): # First call fails with connection error, second succeeds mock_master.hget = AsyncMock( side_effect=[ - redis.exceptions.ConnectionError("Connection failed"), - "recovered_value", + redis.exceptions.ConnectionError('Connection failed'), + 'recovered_value', ] ) mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test async hget with retry - hget_method = proxy.__getattr__("hget") - result = await hget_method("test_hash", "field1") + hget_method = proxy.__getattr__('hget') + result = await hget_method('test_hash', 'field1') - assert result == "recovered_value" + assert result == 'recovered_value' assert mock_master.hget.call_count == 2 # Verify both calls were made with same parameters - expected_calls = [(("test_hash", "field1"),), (("test_hash", "field1"),)] + expected_calls = [(('test_hash', 'field1'),), (('test_hash', 'field1'),)] actual_calls = [call.args for call in mock_master.hget.call_args_list] assert actual_calls == expected_calls @@ -530,7 +518,7 @@ async def test_async_commands_with_failover_retry(self, mock_sentinel_class): class TestSentinelRedisProxyFactoryMethods: """Test Redis factory methods in async mode - these are special cases that remain sync""" - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_pubsub_factory_method_async(self, mock_sentinel_class): """Test pubsub factory method in async mode - should pass through without wrapping""" @@ -542,10 +530,10 @@ async def test_pubsub_factory_method_async(self, mock_sentinel_class): mock_master.pubsub.return_value = mock_pubsub mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test pubsub factory method - should NOT be wrapped as async - pubsub_method = proxy.__getattr__("pubsub") + pubsub_method = proxy.__getattr__('pubsub') result = pubsub_method() assert result == mock_pubsub @@ -554,7 +542,7 @@ async def test_pubsub_factory_method_async(self, mock_sentinel_class): # Verify it's not wrapped as async (no await needed) assert not inspect.iscoroutine(result) - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_pipeline_factory_method_async(self, mock_sentinel_class): """Test pipeline factory method in async mode - should pass through without wrapping""" @@ -566,14 +554,14 @@ async def test_pipeline_factory_method_async(self, mock_sentinel_class): mock_master.pipeline.return_value = mock_pipeline mock_pipeline.set.return_value = mock_pipeline mock_pipeline.get.return_value = mock_pipeline - mock_pipeline.execute.return_value = [True, "test_value"] + mock_pipeline.execute.return_value = [True, 'test_value'] mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test pipeline factory method - should NOT be wrapped as async - pipeline_method = proxy.__getattr__("pipeline") + pipeline_method = proxy.__getattr__('pipeline') result = pipeline_method() assert result == mock_pipeline @@ -583,10 +571,10 @@ async def test_pipeline_factory_method_async(self, mock_sentinel_class): assert not inspect.iscoroutine(result) # Test pipeline usage (these should also be sync) - pipeline_result = result.set("key", "value").get("key").execute() - assert pipeline_result == [True, "test_value"] + pipeline_result = result.set('key', 'value').get('key').execute() + assert pipeline_result == [True, 'test_value'] - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_factory_methods_vs_regular_commands_async(self, mock_sentinel_class): """Test that factory methods behave differently from regular commands in async mode""" @@ -596,19 +584,19 @@ async def test_factory_methods_vs_regular_commands_async(self, mock_sentinel_cla # Mock both factory method and regular command mock_pubsub = Mock() mock_master.pubsub.return_value = mock_pubsub - mock_master.get = AsyncMock(return_value="test_value") + mock_master.get = AsyncMock(return_value='test_value') mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test factory method - should NOT be wrapped - pubsub_method = proxy.__getattr__("pubsub") + pubsub_method = proxy.__getattr__('pubsub') pubsub_result = pubsub_method() # Test regular command - should be wrapped as async - get_method = proxy.__getattr__("get") - get_result = get_method("test_key") + get_method = proxy.__getattr__('get') + get_result = get_method('test_key') # Factory method returns directly assert pubsub_result == mock_pubsub @@ -619,9 +607,9 @@ async def test_factory_methods_vs_regular_commands_async(self, mock_sentinel_cla # Regular command needs await actual_value = await get_result - assert actual_value == "test_value" + assert actual_value == 'test_value' - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_factory_methods_with_failover_async(self, mock_sentinel_class): """Test factory methods with failover in async mode""" @@ -631,16 +619,16 @@ async def test_factory_methods_with_failover_async(self, mock_sentinel_class): # First call fails, second succeeds mock_pubsub = Mock() mock_master.pubsub.side_effect = [ - redis.exceptions.ConnectionError("Connection failed"), + redis.exceptions.ConnectionError('Connection failed'), mock_pubsub, ] mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test pubsub factory method with failover - pubsub_method = proxy.__getattr__("pubsub") + pubsub_method = proxy.__getattr__('pubsub') result = pubsub_method() assert result == mock_pubsub @@ -649,7 +637,7 @@ async def test_factory_methods_with_failover_async(self, mock_sentinel_class): # Verify it's still not wrapped as async after retry assert not inspect.iscoroutine(result) - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_monitor_factory_method_async(self, mock_sentinel_class): """Test monitor factory method in async mode - should pass through without wrapping""" @@ -661,10 +649,10 @@ async def test_monitor_factory_method_async(self, mock_sentinel_class): mock_master.monitor.return_value = mock_monitor mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test monitor factory method - should NOT be wrapped as async - monitor_method = proxy.__getattr__("monitor") + monitor_method = proxy.__getattr__('monitor') result = monitor_method() assert result == mock_monitor @@ -673,7 +661,7 @@ async def test_monitor_factory_method_async(self, mock_sentinel_class): # Verify it's not wrapped as async (no await needed) assert not inspect.iscoroutine(result) - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_client_factory_method_async(self, mock_sentinel_class): """Test client factory method in async mode - should pass through without wrapping""" @@ -685,10 +673,10 @@ async def test_client_factory_method_async(self, mock_sentinel_class): mock_master.client.return_value = mock_client mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test client factory method - should NOT be wrapped as async - client_method = proxy.__getattr__("client") + client_method = proxy.__getattr__('client') result = client_method() assert result == mock_client @@ -697,7 +685,7 @@ async def test_client_factory_method_async(self, mock_sentinel_class): # Verify it's not wrapped as async (no await needed) assert not inspect.iscoroutine(result) - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_transaction_factory_method_async(self, mock_sentinel_class): """Test transaction factory method in async mode - should pass through without wrapping""" @@ -709,10 +697,10 @@ async def test_transaction_factory_method_async(self, mock_sentinel_class): mock_master.transaction.return_value = mock_transaction mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test transaction factory method - should NOT be wrapped as async - transaction_method = proxy.__getattr__("transaction") + transaction_method = proxy.__getattr__('transaction') result = transaction_method() assert result == mock_transaction @@ -721,7 +709,7 @@ async def test_transaction_factory_method_async(self, mock_sentinel_class): # Verify it's not wrapped as async (no await needed) assert not inspect.iscoroutine(result) - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_all_factory_methods_async(self, mock_sentinel_class): """Test all factory methods in async mode - comprehensive test""" @@ -730,11 +718,11 @@ async def test_all_factory_methods_async(self, mock_sentinel_class): # Mock all factory methods mock_objects = { - "pipeline": Mock(), - "pubsub": Mock(), - "monitor": Mock(), - "client": Mock(), - "transaction": Mock(), + 'pipeline': Mock(), + 'pubsub': Mock(), + 'monitor': Mock(), + 'client': Mock(), + 'transaction': Mock(), } for method_name, mock_obj in mock_objects.items(): @@ -742,7 +730,7 @@ async def test_all_factory_methods_async(self, mock_sentinel_class): mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Test all factory methods for method_name, expected_obj in mock_objects.items(): @@ -756,7 +744,7 @@ async def test_all_factory_methods_async(self, mock_sentinel_class): # Reset mock for next iteration getattr(mock_master, method_name).reset_mock() - @patch("redis.sentinel.Sentinel") + @patch('redis.sentinel.Sentinel') @pytest.mark.asyncio async def test_mixed_factory_and_regular_commands_async(self, mock_sentinel_class): """Test using both factory methods and regular commands in async mode""" @@ -768,26 +756,26 @@ async def test_mixed_factory_and_regular_commands_async(self, mock_sentinel_clas mock_master.pipeline.return_value = mock_pipeline mock_pipeline.set.return_value = mock_pipeline mock_pipeline.get.return_value = mock_pipeline - mock_pipeline.execute.return_value = [True, "pipeline_value"] + mock_pipeline.execute.return_value = [True, 'pipeline_value'] - mock_master.get = AsyncMock(return_value="regular_value") + mock_master.get = AsyncMock(return_value='regular_value') mock_sentinel.master_for.return_value = mock_master - proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True) + proxy = SentinelRedisProxy(mock_sentinel, 'mymaster', async_mode=True) # Use factory method (sync) - pipeline = proxy.__getattr__("pipeline")() - pipeline_result = pipeline.set("key1", "value1").get("key1").execute() + pipeline = proxy.__getattr__('pipeline')() + pipeline_result = pipeline.set('key1', 'value1').get('key1').execute() # Use regular command (async) - get_method = proxy.__getattr__("get") - regular_result = await get_method("key2") + get_method = proxy.__getattr__('get') + regular_result = await get_method('key2') # Verify both work correctly - assert pipeline_result == [True, "pipeline_value"] - assert regular_result == "regular_value" + assert pipeline_result == [True, 'pipeline_value'] + assert regular_result == 'regular_value' # Verify calls mock_master.pipeline.assert_called_once() - mock_master.get.assert_called_with("key2") + mock_master.get.assert_called_with('key2') diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index b438759e10..f02a082c42 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -64,14 +64,14 @@ async def get_current_timestamp( now = datetime.datetime.now(datetime.timezone.utc) return json.dumps( { - "current_timestamp": int(now.timestamp()), - "current_iso": now.isoformat(), + 'current_timestamp': int(now.timestamp()), + 'current_iso': now.isoformat(), }, ensure_ascii=False, ) except Exception as e: - log.exception(f"get_current_timestamp error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'get_current_timestamp error: {e}') + return json.dumps({'error': str(e)}) async def calculate_timestamp( @@ -112,10 +112,10 @@ async def calculate_timestamp( return json.dumps( { - "current_timestamp": current_ts, - "current_iso": now.isoformat(), - "calculated_timestamp": adjusted_ts, - "calculated_iso": adjusted.isoformat(), + 'current_timestamp': current_ts, + 'current_iso': now.isoformat(), + 'calculated_timestamp': adjusted_ts, + 'calculated_iso': adjusted.isoformat(), }, ensure_ascii=False, ) @@ -130,16 +130,16 @@ async def calculate_timestamp( adjusted_ts = int(adjusted.timestamp()) return json.dumps( { - "current_timestamp": current_ts, - "current_iso": now.isoformat(), - "calculated_timestamp": adjusted_ts, - "calculated_iso": adjusted.isoformat(), + 'current_timestamp': current_ts, + 'current_iso': now.isoformat(), + 'calculated_timestamp': adjusted_ts, + 'calculated_iso': adjusted.isoformat(), }, ensure_ascii=False, ) except Exception as e: - log.exception(f"calculate_timestamp error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'calculate_timestamp error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -162,14 +162,18 @@ async def search_web( :return: JSON with search results containing title, link, and snippet for each result """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: engine = __request__.app.state.config.WEB_SEARCH_ENGINE user = UserModel(**__user__) if __user__ else None - # Use admin-configured result count if configured, falling back to model-provided count of provided, else default to 5 - count = __request__.app.state.config.WEB_SEARCH_RESULT_COUNT or count + # Enforce maximum result count from config to prevent abuse + count = ( + count + if count < __request__.app.state.config.WEB_SEARCH_RESULT_COUNT + else __request__.app.state.config.WEB_SEARCH_RESULT_COUNT + ) results = await asyncio.to_thread(_search_web, __request__, engine, query, user) @@ -177,12 +181,12 @@ async def search_web( results = results[:count] if results else [] return json.dumps( - [{"title": r.title, "link": r.link, "snippet": r.snippet} for r in results], + [{'title': r.title, 'link': r.link, 'snippet': r.snippet} for r in results], ensure_ascii=False, ) except Exception as e: - log.exception(f"search_web error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'search_web error: {e}') + return json.dumps({'error': str(e)}) async def fetch_url( @@ -197,20 +201,20 @@ async def fetch_url( :return: The extracted text content from the page """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: content, _ = await asyncio.to_thread(get_content_from_url, __request__, url) - # Truncate if too long (avoid overwhelming context) - max_length = 50000 - if len(content) > max_length: - content = content[:max_length] + "\n\n[Content truncated...]" + # Truncate if configured (WEB_FETCH_MAX_CONTENT_LENGTH) + max_length = getattr(__request__.app.state.config, 'WEB_FETCH_MAX_CONTENT_LENGTH', None) + if max_length and max_length > 0 and len(content) > max_length: + content = content[:max_length] + '\n\n[Content truncated...]' return content except Exception as e: - log.exception(f"fetch_url error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'fetch_url error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -233,7 +237,7 @@ async def generate_image( :return: Confirmation that the image was generated, or an error message """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -245,7 +249,7 @@ async def generate_image( ) # Prepare file entries for the images - image_files = [{"type": "image", "url": img["url"]} for img in images] + 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: @@ -261,26 +265,26 @@ async def generate_image( if __event_emitter__ and image_files: await __event_emitter__( { - "type": "chat:message:files", - "data": { - "files": image_files, + 'type': 'chat:message:files', + 'data': { + 'files': image_files, }, } ) # Return a message indicating the image is already displayed return json.dumps( { - "status": "success", - "message": "The image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.", - "images": images, + 'status': 'success', + 'message': 'The image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.', + 'images': images, }, ensure_ascii=False, ) - return json.dumps({"status": "success", "images": images}, ensure_ascii=False) + return json.dumps({'status': 'success', 'images': images}, ensure_ascii=False) except Exception as e: - log.exception(f"generate_image error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'generate_image error: {e}') + return json.dumps({'error': str(e)}) async def edit_image( @@ -300,7 +304,7 @@ async def edit_image( :return: Confirmation that the images were edited, or an error message """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -312,7 +316,7 @@ async def edit_image( ) # Prepare file entries for the images - image_files = [{"type": "image", "url": img["url"]} for img in images] + 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: @@ -328,26 +332,26 @@ async def edit_image( if __event_emitter__ and image_files: await __event_emitter__( { - "type": "chat:message:files", - "data": { - "files": image_files, + 'type': 'chat:message:files', + 'data': { + 'files': image_files, }, } ) # Return a message indicating the image is already displayed return json.dumps( { - "status": "success", - "message": "The edited image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.", - "images": images, + 'status': 'success', + 'message': 'The edited image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.', + 'images': images, }, ensure_ascii=False, ) - return json.dumps({"status": "success", "images": images}, ensure_ascii=False) + return json.dumps({'status': 'success', 'images': images}, ensure_ascii=False) except Exception as e: - log.exception(f"edit_image error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'edit_image error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -376,7 +380,7 @@ async def execute_code( from uuid import uuid4 if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: # Sanitize code (strips ANSI codes and markdown fences) @@ -406,47 +410,39 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): builtins.__import__ = restricted_import """) - code = blocking_code + "\n" + code + code = blocking_code + '\n' + code - engine = getattr( - __request__.app.state.config, "CODE_INTERPRETER_ENGINE", "pyodide" - ) - if engine == "pyodide": + engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide') + if engine == 'pyodide': # Execute via frontend pyodide using bidirectional event call if __event_call__ is None: return json.dumps( - { - "error": "Event call not available. WebSocket connection required for pyodide execution." - } + {'error': 'Event call not available. WebSocket connection required for pyodide execution.'} ) output = await __event_call__( { - "type": "execute:python", - "data": { - "id": str(uuid4()), - "code": code, - "session_id": ( - __metadata__.get("session_id") if __metadata__ else None - ), - "files": ( - __metadata__.get("files", []) if __metadata__ else [] - ), + 'type': 'execute:python', + 'data': { + 'id': str(uuid4()), + 'code': code, + 'session_id': (__metadata__.get('session_id') if __metadata__ else None), + 'files': (__metadata__.get('files', []) if __metadata__ else []), }, } ) # Parse the output - pyodide returns dict with stdout, stderr, result if isinstance(output, dict): - stdout = output.get("stdout", "") - stderr = output.get("stderr", "") - result = output.get("result", "") + stdout = output.get('stdout', '') + stderr = output.get('stderr', '') + result = output.get('result', '') else: - stdout = "" - stderr = "" - result = str(output) if output else "" + stdout = '' + stderr = '' + result = str(output) if output else '' - elif engine == "jupyter": + elif engine == 'jupyter': from open_webui.utils.code_interpreter import execute_code_jupyter output = await execute_code_jupyter( @@ -454,39 +450,37 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): code, ( __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN - if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH - == "token" + if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token' else None ), ( __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD - if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH - == "password" + if __request__.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password' else None ), __request__.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, ) - stdout = output.get("stdout", "") - stderr = output.get("stderr", "") - result = output.get("result", "") + stdout = output.get('stdout', '') + stderr = output.get('stderr', '') + result = output.get('result', '') else: - return json.dumps({"error": f"Unknown code interpreter engine: {engine}"}) + return json.dumps({'error': f'Unknown code interpreter engine: {engine}'}) # Handle image outputs (base64 encoded) - replace with uploaded URLs # Get actual user object for image upload (upload_image requires user.id attribute) - if __user__ and __user__.get("id"): + if __user__ and __user__.get('id'): from open_webui.models.users import Users from open_webui.utils.files import get_image_url_from_base64 - user = Users.get_user_by_id(__user__["id"]) + user = Users.get_user_by_id(__user__['id']) # Extract and upload images from stdout if stdout and isinstance(stdout, str): - stdout_lines = stdout.split("\n") + stdout_lines = stdout.split('\n') for idx, line in enumerate(stdout_lines): - if "data:image/png;base64" in line: + if 'data:image/png;base64' in line: image_url = get_image_url_from_base64( __request__, line, @@ -494,14 +488,14 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): user, ) if image_url: - stdout_lines[idx] = f"![Output Image]({image_url})" - stdout = "\n".join(stdout_lines) + stdout_lines[idx] = f'![Output Image]({image_url})' + stdout = '\n'.join(stdout_lines) # Extract and upload images from result if result and isinstance(result, str): - result_lines = result.split("\n") + result_lines = result.split('\n') for idx, line in enumerate(result_lines): - if "data:image/png;base64" in line: + if 'data:image/png;base64' in line: image_url = get_image_url_from_base64( __request__, line, @@ -509,20 +503,20 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): user, ) if image_url: - result_lines[idx] = f"![Output Image]({image_url})" - result = "\n".join(result_lines) + result_lines[idx] = f'![Output Image]({image_url})' + result = '\n'.join(result_lines) response = { - "status": "success", - "stdout": stdout, - "stderr": stderr, - "result": result, + 'status': 'success', + 'stdout': stdout, + 'stderr': stderr, + 'result': result, } return json.dumps(response, ensure_ascii=False) except Exception as e: - log.exception(f"execute_code error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'execute_code error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -544,7 +538,7 @@ async def search_memories( :return: JSON with matching memories and their dates """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -555,27 +549,25 @@ async def search_memories( user, ) - if results and hasattr(results, "documents") and results.documents: + if results and hasattr(results, 'documents') and results.documents: memories = [] for doc_idx, doc in enumerate(results.documents[0]): memory_id = None if results.ids and results.ids[0]: memory_id = results.ids[0][doc_idx] - created_at = "Unknown" - if results.metadatas and results.metadatas[0][doc_idx].get( - "created_at" - ): + created_at = 'Unknown' + if results.metadatas and results.metadatas[0][doc_idx].get('created_at'): created_at = time.strftime( - "%Y-%m-%d", - time.localtime(results.metadatas[0][doc_idx]["created_at"]), + '%Y-%m-%d', + time.localtime(results.metadatas[0][doc_idx]['created_at']), ) - memories.append({"id": memory_id, "date": created_at, "content": doc}) + memories.append({'id': memory_id, 'date': created_at, 'content': doc}) return json.dumps(memories, ensure_ascii=False) else: return json.dumps([]) except Exception as e: - log.exception(f"search_memories error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'search_memories error: {e}') + return json.dumps({'error': str(e)}) async def add_memory( @@ -590,7 +582,7 @@ async def add_memory( :return: Confirmation that the memory was stored """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -601,10 +593,10 @@ async def add_memory( user, ) - return json.dumps({"status": "success", "id": memory.id}, ensure_ascii=False) + return json.dumps({'status': 'success', 'id': memory.id}, ensure_ascii=False) except Exception as e: - log.exception(f"add_memory error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'add_memory error: {e}') + return json.dumps({'error': str(e)}) async def replace_memory_content( @@ -621,7 +613,7 @@ async def replace_memory_content( :return: Confirmation that the memory was updated """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -634,12 +626,12 @@ async def replace_memory_content( ) return json.dumps( - {"status": "success", "id": memory.id, "content": memory.content}, + {'status': 'success', 'id': memory.id, 'content': memory.content}, ensure_ascii=False, ) except Exception as e: - log.exception(f"replace_memory_content error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'replace_memory_content error: {e}') + return json.dumps({'error': str(e)}) async def delete_memory( @@ -654,7 +646,7 @@ async def delete_memory( :return: Confirmation that the memory was deleted """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -662,18 +654,16 @@ async def delete_memory( result = Memories.delete_memory_by_id_and_user_id(memory_id, user.id) if result: - VECTOR_DB_CLIENT.delete( - collection_name=f"user-memory-{user.id}", ids=[memory_id] - ) + VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=[memory_id]) return json.dumps( - {"status": "success", "message": f"Memory {memory_id} deleted"}, + {'status': 'success', 'message': f'Memory {memory_id} deleted'}, ensure_ascii=False, ) else: - return json.dumps({"error": "Memory not found or access denied"}) + return json.dumps({'error': 'Memory not found or access denied'}) except Exception as e: - log.exception(f"delete_memory error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'delete_memory error: {e}') + return json.dumps({'error': str(e)}) async def list_memories( @@ -686,7 +676,7 @@ async def list_memories( :return: JSON list of all memories with id, content, and dates """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) try: user = UserModel(**__user__) if __user__ else None @@ -696,14 +686,10 @@ async def list_memories( if memories: result = [ { - "id": m.id, - "content": m.content, - "created_at": time.strftime( - "%Y-%m-%d %H:%M", time.localtime(m.created_at) - ), - "updated_at": time.strftime( - "%Y-%m-%d %H:%M", time.localtime(m.updated_at) - ), + 'id': m.id, + 'content': m.content, + 'created_at': time.strftime('%Y-%m-%d %H:%M', time.localtime(m.created_at)), + 'updated_at': time.strftime('%Y-%m-%d %H:%M', time.localtime(m.updated_at)), } for m in memories ] @@ -711,8 +697,8 @@ async def list_memories( else: return json.dumps([]) except Exception as e: - log.exception(f"list_memories error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'list_memories error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -738,22 +724,22 @@ async def search_notes( :return: JSON with matching notes containing id, title, and content snippet """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: - user_id = __user__.get("id") + user_id = __user__.get('id') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] result = Notes.search_notes( user_id=user_id, filter={ - "query": query, - "user_id": user_id, - "group_ids": user_group_ids, - "permission": "read", + 'query': query, + 'user_id': user_id, + 'group_ids': user_group_ids, + 'permission': 'read', }, skip=0, limit=count * 3, # Fetch more for filtering @@ -772,9 +758,9 @@ async def search_notes( continue # Extract a snippet from the markdown content - content_snippet = "" - if note.data and note.data.get("content", {}).get("md"): - md_content = note.data["content"]["md"] + content_snippet = '' + if note.data and note.data.get('content', {}).get('md'): + md_content = note.data['content']['md'] lower_content = md_content.lower() lower_query = query.lower() idx = lower_content.find(lower_query) @@ -782,21 +768,17 @@ async def search_notes( start = max(0, idx - 50) end = min(len(md_content), idx + len(query) + 100) content_snippet = ( - ("..." if start > 0 else "") - + md_content[start:end] - + ("..." if end < len(md_content) else "") + ('...' if start > 0 else '') + md_content[start:end] + ('...' if end < len(md_content) else '') ) else: - content_snippet = md_content[:150] + ( - "..." if len(md_content) > 150 else "" - ) + content_snippet = md_content[:150] + ('...' if len(md_content) > 150 else '') notes.append( { - "id": note.id, - "title": note.title, - "snippet": content_snippet, - "updated_at": note.updated_at, + 'id': note.id, + 'title': note.title, + 'snippet': content_snippet, + 'updated_at': note.updated_at, } ) @@ -805,8 +787,8 @@ async def search_notes( return json.dumps(notes, ensure_ascii=False) except Exception as e: - log.exception(f"search_notes error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'search_notes error: {e}') + return json.dumps({'error': str(e)}) async def view_note( @@ -821,50 +803,50 @@ async def view_note( :return: JSON with the note's id, title, and full markdown content """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: note = Notes.get_note_by_id(note_id) if not note: - return json.dumps({"error": "Note not found"}) + return json.dumps({'error': 'Note not found'}) # Check access permission - user_id = __user__.get("id") + user_id = __user__.get('id') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] from open_webui.models.access_grants import AccessGrants if note.user_id != user_id and not AccessGrants.has_access( user_id=user_id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="read", + permission='read', user_group_ids=set(user_group_ids), ): - return json.dumps({"error": "Access denied"}) + return json.dumps({'error': 'Access denied'}) # Extract markdown content - content = "" - if note.data and note.data.get("content", {}).get("md"): - content = note.data["content"]["md"] + content = '' + if note.data and note.data.get('content', {}).get('md'): + content = note.data['content']['md'] return json.dumps( { - "id": note.id, - "title": note.title, - "content": content, - "updated_at": note.updated_at, - "created_at": note.created_at, + 'id': note.id, + 'title': note.title, + 'content': content, + 'updated_at': note.updated_at, + 'created_at': note.created_at, }, ensure_ascii=False, ) except Exception as e: - log.exception(f"view_note error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'view_note error: {e}') + return json.dumps({'error': str(e)}) async def write_note( @@ -881,39 +863,39 @@ async def write_note( :return: JSON with success status and new note id """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: from open_webui.models.notes import NoteForm - user_id = __user__.get("id") + user_id = __user__.get('id') form = NoteForm( title=title, - data={"content": {"md": content}}, + data={'content': {'md': content}}, access_grants=[], # Private by default - only owner can access ) new_note = Notes.insert_new_note(user_id, form) if not new_note: - return json.dumps({"error": "Failed to create note"}) + return json.dumps({'error': 'Failed to create note'}) return json.dumps( { - "status": "success", - "id": new_note.id, - "title": new_note.title, - "created_at": new_note.created_at, + 'status': 'success', + 'id': new_note.id, + 'title': new_note.title, + 'created_at': new_note.created_at, }, ensure_ascii=False, ) except Exception as e: - log.exception(f"write_note error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'write_note error: {e}') + return json.dumps({'error': str(e)}) async def replace_note_content( @@ -932,10 +914,10 @@ async def replace_note_content( :return: JSON with success status and updated note info """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: from open_webui.models.notes import NoteUpdateForm @@ -943,46 +925,46 @@ async def replace_note_content( note = Notes.get_note_by_id(note_id) if not note: - return json.dumps({"error": "Note not found"}) + return json.dumps({'error': 'Note not found'}) # Check write permission - user_id = __user__.get("id") + user_id = __user__.get('id') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] from open_webui.models.access_grants import AccessGrants if note.user_id != user_id and not AccessGrants.has_access( user_id=user_id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="write", + permission='write', user_group_ids=set(user_group_ids), ): - return json.dumps({"error": "Write access denied"}) + return json.dumps({'error': 'Write access denied'}) # Build update form - update_data = {"data": {"content": {"md": content}}} + update_data = {'data': {'content': {'md': content}}} if title: - update_data["title"] = title + update_data['title'] = title form = NoteUpdateForm(**update_data) updated_note = 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'}) return json.dumps( { - "status": "success", - "id": updated_note.id, - "title": updated_note.title, - "updated_at": updated_note.updated_at, + 'status': 'success', + 'id': updated_note.id, + 'title': updated_note.title, + 'updated_at': updated_note.updated_at, }, ensure_ascii=False, ) except Exception as e: - log.exception(f"replace_note_content error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'replace_note_content error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -1009,13 +991,13 @@ async def search_chats( :return: JSON with matching chats containing id, title, updated_at, and content snippet """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: - user_id = __user__.get("id") + user_id = __user__.get('id') chats = Chats.get_chats_by_user_id_and_search_text( user_id=user_id, @@ -1038,32 +1020,28 @@ async def search_chats( continue # Find a matching message snippet - snippet = "" - messages = chat.chat.get("history", {}).get("messages", {}) + snippet = '' + messages = chat.chat.get('history', {}).get('messages', {}) lower_query = query.lower() for msg_id, msg in messages.items(): - content = msg.get("content", "") + content = msg.get('content', '') if isinstance(content, str) and lower_query in content.lower(): idx = content.lower().find(lower_query) start = max(0, idx - 50) end = min(len(content), idx + len(query) + 100) - snippet = ( - ("..." if start > 0 else "") - + content[start:end] - + ("..." if end < len(content) else "") - ) + snippet = ('...' if start > 0 else '') + content[start:end] + ('...' if end < len(content) else '') break if not snippet and lower_query in chat.title.lower(): - snippet = f"Title match: {chat.title}" + snippet = f'Title match: {chat.title}' results.append( { - "id": chat.id, - "title": chat.title, - "snippet": snippet, - "updated_at": chat.updated_at, + 'id': chat.id, + 'title': chat.title, + 'snippet': snippet, + 'updated_at': chat.updated_at, } ) @@ -1072,8 +1050,8 @@ async def search_chats( return json.dumps(results, ensure_ascii=False) except Exception as e: - log.exception(f"search_chats error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'search_chats error: {e}') + return json.dumps({'error': str(e)}) async def view_chat( @@ -1088,26 +1066,26 @@ async def view_chat( :return: JSON with the chat's id, title, and messages """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: - user_id = __user__.get("id") + user_id = __user__.get('id') chat = Chats.get_chat_by_id_and_user_id(chat_id, user_id) if not chat: - return json.dumps({"error": "Chat not found or access denied"}) + return json.dumps({'error': 'Chat not found or access denied'}) # Extract messages from history messages = [] - history = chat.chat.get("history", {}) - msg_dict = history.get("messages", {}) + history = chat.chat.get('history', {}) + msg_dict = history.get('messages', {}) # Build message chain from currentId - current_id = history.get("currentId") + current_id = history.get('currentId') visited = set() while current_id and current_id not in visited: @@ -1116,28 +1094,28 @@ async def view_chat( if msg: messages.append( { - "role": msg.get("role", ""), - "content": msg.get("content", ""), + 'role': msg.get('role', ''), + 'content': msg.get('content', ''), } ) - current_id = msg.get("parentId") if msg else None + current_id = msg.get('parentId') if msg else None # Reverse to get chronological order messages.reverse() return json.dumps( { - "id": chat.id, - "title": chat.title, - "messages": messages, - "updated_at": chat.updated_at, - "created_at": chat.created_at, + 'id': chat.id, + 'title': chat.title, + 'messages': messages, + 'updated_at': chat.updated_at, + 'created_at': chat.created_at, }, ensure_ascii=False, ) except Exception as e: - log.exception(f"view_chat error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'view_chat error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -1159,13 +1137,13 @@ async def search_channels( :return: JSON with matching channels containing id, name, description, and type """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: - user_id = __user__.get("id") + user_id = __user__.get('id') # Get all channels the user has access to all_channels = Channels.get_channels_by_user_id(user_id) @@ -1176,15 +1154,15 @@ async def search_channels( for channel in all_channels: name_match = lower_query in channel.name.lower() if channel.name else False - desc_match = lower_query in (channel.description or "").lower() + desc_match = lower_query in (channel.description or '').lower() if name_match or desc_match: matching_channels.append( { - "id": channel.id, - "name": channel.name, - "description": channel.description or "", - "type": channel.type or "public", + 'id': channel.id, + 'name': channel.name, + 'description': channel.description or '', + 'type': channel.type or 'public', } ) @@ -1193,8 +1171,8 @@ async def search_channels( return json.dumps(matching_channels, ensure_ascii=False) except Exception as e: - log.exception(f"search_channels error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'search_channels error: {e}') + return json.dumps({'error': str(e)}) async def search_channel_messages( @@ -1215,13 +1193,13 @@ async def search_channel_messages( :return: JSON with matching messages containing channel info, message content, and thread context """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: - user_id = __user__.get("id") + user_id = __user__.get('id') # Get all channels the user has access to user_channels = Channels.get_channels_by_user_id(user_id) @@ -1249,36 +1227,32 @@ async def search_channel_messages( channel = channel_map.get(msg.channel_id) # Extract snippet around the match - content = msg.content or "" + content = msg.content or '' lower_query = query.lower() idx = content.lower().find(lower_query) if idx != -1: start = max(0, idx - 50) end = min(len(content), idx + len(query) + 100) - snippet = ( - ("..." if start > 0 else "") - + content[start:end] - + ("..." if end < len(content) else "") - ) + snippet = ('...' if start > 0 else '') + content[start:end] + ('...' if end < len(content) else '') else: - snippet = content[:150] + ("..." if len(content) > 150 else "") + snippet = content[:150] + ('...' if len(content) > 150 else '') results.append( { - "channel_id": msg.channel_id, - "channel_name": channel.name if channel else "Unknown", - "message_id": msg.id, - "content_snippet": snippet, - "is_thread_reply": msg.parent_id is not None, - "parent_id": msg.parent_id, - "created_at": msg.created_at, + 'channel_id': msg.channel_id, + 'channel_name': channel.name if channel else 'Unknown', + 'message_id': msg.id, + 'content_snippet': snippet, + 'is_thread_reply': msg.parent_id is not None, + 'parent_id': msg.parent_id, + 'created_at': msg.created_at, } ) return json.dumps(results, ensure_ascii=False) except Exception as e: - log.exception(f"search_channel_messages error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'search_channel_messages error: {e}') + return json.dumps({'error': str(e)}) async def view_channel_message( @@ -1293,53 +1267,53 @@ async def view_channel_message( :return: JSON with the message content, channel info, and thread replies if any """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: - user_id = __user__.get("id") + user_id = __user__.get('id') message = Messages.get_message_by_id(message_id) if not message: - return json.dumps({"error": "Message not found"}) + return json.dumps({'error': 'Message not found'}) # Verify user has access to the channel channel = Channels.get_channel_by_id(message.channel_id) if not channel: - return json.dumps({"error": "Channel not found"}) + return json.dumps({'error': 'Channel not found'}) # Check if user has access to the channel user_channels = Channels.get_channels_by_user_id(user_id) channel_ids = [c.id for c in user_channels] if message.channel_id not in channel_ids: - return json.dumps({"error": "Access denied"}) + return json.dumps({'error': 'Access denied'}) # Build response with thread information result = { - "id": message.id, - "channel_id": message.channel_id, - "channel_name": channel.name, - "content": message.content, - "user_id": message.user_id, - "is_thread_reply": message.parent_id is not None, - "parent_id": message.parent_id, - "reply_count": message.reply_count, - "created_at": message.created_at, - "updated_at": message.updated_at, + 'id': message.id, + 'channel_id': message.channel_id, + 'channel_name': channel.name, + 'content': message.content, + 'user_id': message.user_id, + 'is_thread_reply': message.parent_id is not None, + 'parent_id': message.parent_id, + 'reply_count': message.reply_count, + 'created_at': message.created_at, + 'updated_at': message.updated_at, } # Include user info if available if message.user: - result["user_name"] = message.user.name + result['user_name'] = message.user.name return json.dumps(result, ensure_ascii=False) except Exception as e: - log.exception(f"view_channel_message error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'view_channel_message error: {e}') + return json.dumps({'error': str(e)}) async def view_channel_thread( @@ -1354,30 +1328,30 @@ async def view_channel_thread( :return: JSON with the parent message and all thread replies in chronological order """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: - user_id = __user__.get("id") + user_id = __user__.get('id') # Get the parent message parent_message = Messages.get_message_by_id(parent_message_id) if not parent_message: - return json.dumps({"error": "Message not found"}) + return json.dumps({'error': 'Message not found'}) # Verify user has access to the channel channel = Channels.get_channel_by_id(parent_message.channel_id) if not channel: - return json.dumps({"error": "Channel not found"}) + return json.dumps({'error': 'Channel not found'}) user_channels = Channels.get_channels_by_user_id(user_id) channel_ids = [c.id for c in user_channels] if parent_message.channel_id not in channel_ids: - return json.dumps({"error": "Access denied"}) + return json.dumps({'error': 'Access denied'}) # Get all thread replies thread_replies = Messages.get_thread_replies_by_message_id(parent_message_id) @@ -1388,12 +1362,12 @@ async def view_channel_thread( # Add parent message first messages.append( { - "id": parent_message.id, - "content": parent_message.content, - "user_id": parent_message.user_id, - "user_name": parent_message.user.name if parent_message.user else None, - "is_parent": True, - "created_at": parent_message.created_at, + 'id': parent_message.id, + 'content': parent_message.content, + 'user_id': parent_message.user_id, + 'user_name': parent_message.user.name if parent_message.user else None, + 'is_parent': True, + 'created_at': parent_message.created_at, } ) @@ -1401,29 +1375,29 @@ async def view_channel_thread( for reply in reversed(thread_replies): messages.append( { - "id": reply.id, - "content": reply.content, - "user_id": reply.user_id, - "user_name": reply.user.name if reply.user else None, - "is_parent": False, - "reply_to_id": reply.reply_to_id, - "created_at": reply.created_at, + 'id': reply.id, + 'content': reply.content, + 'user_id': reply.user_id, + 'user_name': reply.user.name if reply.user else None, + 'is_parent': False, + 'reply_to_id': reply.reply_to_id, + 'created_at': reply.created_at, } ) return json.dumps( { - "channel_id": parent_message.channel_id, - "channel_name": channel.name, - "thread_id": parent_message_id, - "message_count": len(messages), - "messages": messages, + 'channel_id': parent_message.channel_id, + 'channel_name': channel.name, + 'thread_id': parent_message_id, + 'message_count': len(messages), + 'messages': messages, }, ensure_ascii=False, ) except Exception as e: - log.exception(f"view_channel_thread error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'view_channel_thread error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -1445,23 +1419,23 @@ async def list_knowledge_bases( :return: JSON with KBs containing id, name, description, and file_count """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: from open_webui.models.knowledge import Knowledges - user_id = __user__.get("id") + user_id = __user__.get('id') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] result = Knowledges.search_knowledge_bases( user_id, filter={ - "query": "", - "user_id": user_id, - "group_ids": user_group_ids, + 'query': '', + 'user_id': user_id, + 'group_ids': user_group_ids, }, skip=skip, limit=count, @@ -1474,18 +1448,18 @@ async def list_knowledge_bases( knowledge_bases.append( { - "id": knowledge_base.id, - "name": knowledge_base.name, - "description": knowledge_base.description or "", - "file_count": file_count, - "updated_at": knowledge_base.updated_at, + 'id': knowledge_base.id, + 'name': knowledge_base.name, + 'description': knowledge_base.description or '', + 'file_count': file_count, + 'updated_at': knowledge_base.updated_at, } ) return json.dumps(knowledge_bases, ensure_ascii=False) except Exception as e: - log.exception(f"list_knowledge_bases error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'list_knowledge_bases error: {e}') + return json.dumps({'error': str(e)}) async def search_knowledge_bases( @@ -1504,23 +1478,23 @@ async def search_knowledge_bases( :return: JSON with matching KBs containing id, name, description, and file_count """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: from open_webui.models.knowledge import Knowledges - user_id = __user__.get("id") + user_id = __user__.get('id') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] result = Knowledges.search_knowledge_bases( user_id, filter={ - "query": query, - "user_id": user_id, - "group_ids": user_group_ids, + 'query': query, + 'user_id': user_id, + 'group_ids': user_group_ids, }, skip=skip, limit=count, @@ -1533,18 +1507,18 @@ async def search_knowledge_bases( knowledge_bases.append( { - "id": knowledge_base.id, - "name": knowledge_base.name, - "description": knowledge_base.description or "", - "file_count": file_count, - "updated_at": knowledge_base.updated_at, + 'id': knowledge_base.id, + 'name': knowledge_base.name, + 'description': knowledge_base.description or '', + 'file_count': file_count, + 'updated_at': knowledge_base.updated_at, } ) return json.dumps(knowledge_bases, ensure_ascii=False) except Exception as e: - log.exception(f"search_knowledge_bases error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'search_knowledge_bases error: {e}') + return json.dumps({'error': str(e)}) async def search_knowledge_files( @@ -1554,9 +1528,11 @@ async def search_knowledge_files( skip: int = 0, __request__: Request = None, __user__: dict = None, + __model_knowledge__: Optional[list[dict]] = None, ) -> str: """ - Search files across knowledge bases the user has access to. + Search files by filename across knowledge bases the user has access to. + When the model has attached knowledge, searches only within attached KBs and files. :param query: The search query to find matching files by filename :param knowledge_id: Optional KB id to limit search to a specific knowledge base @@ -1565,31 +1541,112 @@ async def search_knowledge_files( :return: JSON with matching files containing id, filename, and updated_at """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: from open_webui.models.knowledge import Knowledges + from open_webui.models.files import Files + from open_webui.models.access_grants import AccessGrants - user_id = __user__.get("id") + user_id = __user__.get('id') + user_role = __user__.get('role', 'user') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + # When model has attached knowledge, scope to attached KBs/files only + if __model_knowledge__: + attached_kb_ids = set() + attached_file_ids = set() + + for item in __model_knowledge__: + item_type = item.get('type') + item_id = item.get('id') + if item_type == 'collection': + attached_kb_ids.add(item_id) + elif item_type == 'file': + attached_file_ids.add(item_id) + + # If knowledge_id specified, verify it's in the attached set + if knowledge_id: + if knowledge_id not in attached_kb_ids: + return json.dumps({'error': f'Knowledge base {knowledge_id} is not attached to this model'}) + attached_kb_ids = {knowledge_id} + + all_files = [] + + # Search within attached KBs + for kb_id in attached_kb_ids: + knowledge = Knowledges.get_knowledge_by_id(kb_id) + if not knowledge: + continue + + if not ( + user_role == 'admin' + or knowledge.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type='knowledge', + resource_id=knowledge.id, + permission='read', + user_group_ids=set(user_group_ids), + ) + ): + continue + + result = Knowledges.search_files_by_id( + knowledge_id=kb_id, + user_id=user_id, + filter={'query': query}, + skip=0, + limit=count + skip, + ) + + for file in result.items: + all_files.append( + { + 'id': file.id, + 'filename': file.filename, + 'knowledge_id': knowledge.id, + 'knowledge_name': knowledge.name, + 'updated_at': file.updated_at, + } + ) + + # Search within directly attached files (filename match) + if not knowledge_id and attached_file_ids: + query_lower = query.lower() if query else '' + for file_id in attached_file_ids: + file = Files.get_file_by_id(file_id) + if file and (not query_lower or query_lower in file.filename.lower()): + all_files.append( + { + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, + } + ) + + # Apply pagination across combined results + all_files = all_files[skip : skip + count] + return json.dumps(all_files, ensure_ascii=False) + + # No attached knowledge - search all accessible KBs if knowledge_id: result = Knowledges.search_files_by_id( knowledge_id=knowledge_id, user_id=user_id, - filter={"query": query}, + filter={'query': query}, skip=skip, limit=count, ) else: result = Knowledges.search_knowledge_files( filter={ - "query": query, - "user_id": user_id, - "group_ids": user_group_ids, + 'query': query, + 'user_id': user_id, + 'group_ids': user_group_ids, }, skip=skip, limit=count, @@ -1598,113 +1655,168 @@ async def search_knowledge_files( files = [] for file in result.items: file_info = { - "id": file.id, - "filename": file.filename, - "updated_at": file.updated_at, + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, } - if hasattr(file, "collection") and file.collection: - file_info["knowledge_id"] = file.collection.get("id", "") - file_info["knowledge_name"] = file.collection.get("name", "") + if hasattr(file, 'collection') and file.collection: + file_info['knowledge_id'] = file.collection.get('id', '') + file_info['knowledge_name'] = file.collection.get('name', '') files.append(file_info) return json.dumps(files, ensure_ascii=False) except Exception as e: - log.exception(f"search_knowledge_files error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'search_knowledge_files error: {e}') + return json.dumps({'error': str(e)}) + + +# Hard cap for view_file / view_knowledge_file output +MAX_VIEW_FILE_CHARS = 100_000 +DEFAULT_VIEW_FILE_MAX_CHARS = 10_000 async def view_file( file_id: str, + offset: int = 0, + max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS, __request__: Request = None, __user__: dict = None, __model_knowledge__: Optional[list[dict]] = None, ) -> str: """ - Get the full content of a file by its ID. + Get the content of a file by its ID. Supports pagination for large files. :param file_id: The ID of the file to retrieve - :return: JSON with the file's id, filename, and full text content + :param offset: Character offset to start reading from (default: 0) + :param max_chars: Maximum characters to return (default: 10000, hard cap: 100000) + :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) + + # Coerce parameters from LLM tool calls (may come as strings) + if isinstance(offset, str): + try: + offset = int(offset) + except ValueError: + offset = 0 + if isinstance(max_chars, str): + try: + max_chars = int(max_chars) + except ValueError: + max_chars = DEFAULT_VIEW_FILE_MAX_CHARS + + # Enforce hard cap + max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS) + offset = max(offset, 0) try: from open_webui.models.files import Files from open_webui.utils.access_control.files import has_access_to_file - user_id = __user__.get("id") - user_role = __user__.get("role", "user") + user_id = __user__.get('id') + user_role = __user__.get('role', 'user') file = Files.get_file_by_id(file_id) if not file: - return json.dumps({"error": "File not found"}) + return json.dumps({'error': 'File not found'}) if ( file.user_id != user_id - and user_role != "admin" + and user_role != 'admin' and not any( - item.get("type") == "file" and item.get("id") == file_id - for item in (__model_knowledge__ or []) + item.get('type') == 'file' and item.get('id') == file_id for item in (__model_knowledge__ or []) ) and not has_access_to_file( file_id=file_id, - access_type="read", + access_type='read', user=UserModel(**__user__), ) ): - return json.dumps({"error": "File not found"}) + return json.dumps({'error': 'File not found'}) - content = "" + content = '' if file.data: - content = file.data.get("content", "") + content = file.data.get('content', '') - return json.dumps( - { - "id": file.id, - "filename": file.filename, - "content": content, - "updated_at": file.updated_at, - "created_at": file.created_at, - }, - ensure_ascii=False, - ) + total_chars = len(content) + sliced = content[offset : offset + max_chars] + is_truncated = (offset + len(sliced)) < total_chars + + result = { + 'id': file.id, + 'filename': file.filename, + 'content': sliced, + 'updated_at': file.updated_at, + 'created_at': file.created_at, + } + + if is_truncated or offset > 0: + result['truncated'] = is_truncated + result['total_chars'] = total_chars + result['returned_chars'] = len(sliced) + result['offset'] = offset + if is_truncated: + result['next_offset'] = offset + len(sliced) + + return json.dumps(result, ensure_ascii=False) except Exception as e: - log.exception(f"view_file error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'view_file error: {e}') + return json.dumps({'error': str(e)}) async def view_knowledge_file( file_id: str, + offset: int = 0, + max_chars: int = DEFAULT_VIEW_FILE_MAX_CHARS, __request__: Request = None, __user__: dict = None, ) -> str: """ - Get the full content of a file from a knowledge base. + Get the content of a file from a knowledge base. Supports pagination for large files. :param file_id: The ID of the file to retrieve - :return: JSON with the file's id, filename, and full text content + :param offset: Character offset to start reading from (default: 0) + :param max_chars: Maximum characters to return (default: 10000, hard cap: 100000) + :return: JSON with the file's id, filename, content, and pagination metadata if truncated """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) + + # Coerce parameters from LLM tool calls (may come as strings) + if isinstance(offset, str): + try: + offset = int(offset) + except ValueError: + offset = 0 + if isinstance(max_chars, str): + try: + max_chars = int(max_chars) + except ValueError: + max_chars = DEFAULT_VIEW_FILE_MAX_CHARS + + # Enforce hard cap + max_chars = min(max(max_chars, 1), MAX_VIEW_FILE_CHARS) + offset = max(offset, 0) try: from open_webui.models.files import Files from open_webui.models.knowledge import Knowledges from open_webui.models.access_grants import AccessGrants - user_id = __user__.get("id") - user_role = __user__.get("role", "user") + user_id = __user__.get('id') + user_role = __user__.get('role', 'user') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] file = Files.get_file_by_id(file_id) if not file: - return json.dumps({"error": "File not found"}) + return json.dumps({'error': 'File not found'}) # Check access via any KB containing this file knowledges = Knowledges.get_knowledges_by_file_id(file_id) @@ -1713,43 +1825,165 @@ async def view_knowledge_file( for knowledge_base in knowledges: if ( - user_role == "admin" + user_role == 'admin' or knowledge_base.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge_base.id, - permission="read", + permission='read', user_group_ids=set(user_group_ids), ) ): has_knowledge_access = True - knowledge_info = {"id": knowledge_base.id, "name": knowledge_base.name} + knowledge_info = {'id': knowledge_base.id, 'name': knowledge_base.name} break if not has_knowledge_access: - if file.user_id != user_id and user_role != "admin": - return json.dumps({"error": "Access denied"}) + if file.user_id != user_id and user_role != 'admin': + return json.dumps({'error': 'Access denied'}) - content = "" + content = '' if file.data: - content = file.data.get("content", "") + content = file.data.get('content', '') + + total_chars = len(content) + sliced = content[offset : offset + max_chars] + is_truncated = (offset + len(sliced)) < total_chars result = { - "id": file.id, - "filename": file.filename, - "content": content, - "updated_at": file.updated_at, - "created_at": file.created_at, + 'id': file.id, + 'filename': file.filename, + 'content': sliced, + 'updated_at': file.updated_at, + 'created_at': file.created_at, } if knowledge_info: - result["knowledge_id"] = knowledge_info["id"] - result["knowledge_name"] = knowledge_info["name"] + result['knowledge_id'] = knowledge_info['id'] + result['knowledge_name'] = knowledge_info['name'] + + if is_truncated or offset > 0: + result['truncated'] = is_truncated + result['total_chars'] = total_chars + result['returned_chars'] = len(sliced) + result['offset'] = offset + if is_truncated: + result['next_offset'] = offset + len(sliced) return json.dumps(result, ensure_ascii=False) except Exception as e: - log.exception(f"view_knowledge_file error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'view_knowledge_file error: {e}') + return json.dumps({'error': str(e)}) + + +async def list_knowledge( + __request__: Request = None, + __user__: dict = None, + __model_knowledge__: Optional[list[dict]] = None, +) -> str: + """ + List all knowledge bases, files, and notes attached to the current model. + Use this first to discover what knowledge is available before querying or reading files. + + :return: JSON with knowledge_bases, files, and notes attached to this model + """ + 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 __model_knowledge__: + return json.dumps({'knowledge_bases': [], 'files': [], 'notes': []}) + + try: + from open_webui.models.knowledge import Knowledges + from open_webui.models.files import Files + from open_webui.models.notes import Notes + from open_webui.models.access_grants import AccessGrants + + user_id = __user__.get('id') + user_role = __user__.get('role', 'user') + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + + knowledge_bases = [] + files = [] + notes = [] + + for item in __model_knowledge__: + item_type = item.get('type') + item_id = item.get('id') + + if item_type == 'collection': + knowledge = Knowledges.get_knowledge_by_id(item_id) + if knowledge and ( + user_role == 'admin' + or knowledge.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type='knowledge', + resource_id=knowledge.id, + permission='read', + user_group_ids=set(user_group_ids), + ) + ): + kb_files = Knowledges.get_files_by_id(knowledge.id) + file_count = len(kb_files) if kb_files else 0 + + kb_entry = { + 'id': knowledge.id, + 'name': knowledge.name, + 'description': knowledge.description or '', + 'file_count': file_count, + } + + # Include file listing for each KB + if kb_files: + kb_entry['files'] = [{'id': f.id, 'filename': f.filename} for f in kb_files] + + knowledge_bases.append(kb_entry) + + elif item_type == 'file': + file = Files.get_file_by_id(item_id) + if file: + files.append( + { + 'id': file.id, + 'filename': file.filename, + 'updated_at': file.updated_at, + } + ) + + elif item_type == 'note': + note = Notes.get_note_by_id(item_id) + if note and ( + user_role == 'admin' + or note.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type='note', + resource_id=note.id, + permission='read', + ) + ): + notes.append( + { + 'id': note.id, + 'title': note.title, + } + ) + + return json.dumps( + { + 'knowledge_bases': knowledge_bases, + 'files': files, + 'notes': notes, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'list_knowledge error: {e}') + return json.dumps({'error': str(e)}) async def query_knowledge_files( @@ -1770,10 +2004,10 @@ async def query_knowledge_files( :return: JSON with relevant chunks containing content, source filename, and relevance score """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) # Coerce parameters from LLM tool calls (may come as strings) if isinstance(count, str): @@ -1784,7 +2018,7 @@ async def query_knowledge_files( # Handle knowledge_ids being string "None", "null", or empty if isinstance(knowledge_ids, str): - if knowledge_ids.lower() in ("none", "null", ""): + if knowledge_ids.lower() in ('none', 'null', ''): knowledge_ids = None else: # Try to parse as JSON array if it looks like one @@ -1801,13 +2035,13 @@ async def query_knowledge_files( from open_webui.retrieval.utils import query_collection from open_webui.models.access_grants import AccessGrants - user_id = __user__.get("id") - user_role = __user__.get("role", "user") + user_id = __user__.get('id') + user_role = __user__.get('role', 'user') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] embedding_function = __request__.app.state.EMBEDDING_FUNCTION if not embedding_function: - return json.dumps({"error": "Embedding function not configured"}) + return json.dumps({'error': 'Embedding function not configured'}) collection_names = [] note_results = [] # Notes aren't vectorized, handle separately @@ -1815,51 +2049,51 @@ async def query_knowledge_files( # If model has attached knowledge, use those if __model_knowledge__: for item in __model_knowledge__: - item_type = item.get("type") - item_id = item.get("id") + item_type = item.get('type') + item_id = item.get('id') - if item_type == "collection": + if item_type == 'collection': # Knowledge base - use KB ID as collection name knowledge = Knowledges.get_knowledge_by_id(item_id) if knowledge and ( - user_role == "admin" + user_role == 'admin' or knowledge.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="read", + permission='read', user_group_ids=set(user_group_ids), ) ): collection_names.append(item_id) - elif item_type == "file": + elif item_type == 'file': # Individual file - use file-{id} as collection name file = Files.get_file_by_id(item_id) if file: - collection_names.append(f"file-{item_id}") + collection_names.append(f'file-{item_id}') - elif item_type == "note": + elif item_type == 'note': # Note - always return full content as context note = Notes.get_note_by_id(item_id) if note and ( - user_role == "admin" + user_role == 'admin' or note.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="note", + resource_type='note', resource_id=note.id, - permission="read", + permission='read', ) ): - content = note.data.get("content", {}).get("md", "") + content = note.data.get('content', {}).get('md', '') note_results.append( { - "content": content, - "source": note.title, - "note_id": note.id, - "type": "note", + 'content': content, + 'source': note.title, + 'note_id': note.id, + 'type': 'note', } ) @@ -1868,13 +2102,13 @@ async def query_knowledge_files( for knowledge_id in knowledge_ids: knowledge = Knowledges.get_knowledge_by_id(knowledge_id) if knowledge and ( - user_role == "admin" + user_role == 'admin' or knowledge.user_id == user_id or AccessGrants.has_access( user_id=user_id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge.id, - permission="read", + permission='read', user_group_ids=set(user_group_ids), ) ): @@ -1884,9 +2118,9 @@ async def query_knowledge_files( result = Knowledges.search_knowledge_bases( user_id, filter={ - "query": "", - "user_id": user_id, - "group_ids": user_group_ids, + 'query': '', + 'user_id': user_id, + 'group_ids': user_group_ids, }, skip=0, limit=50, @@ -1901,27 +2135,26 @@ async def query_knowledge_files( # Query vector collections if any if collection_names: query_results = await query_collection( + __request__, collection_names=collection_names, queries=[query], embedding_function=embedding_function, k=count, ) - if query_results and "documents" in query_results: - documents = query_results.get("documents", [[]])[0] - metadatas = query_results.get("metadatas", [[]])[0] - distances = query_results.get("distances", [[]])[0] + if query_results and 'documents' in query_results: + documents = query_results.get('documents', [[]])[0] + metadatas = query_results.get('metadatas', [[]])[0] + distances = query_results.get('distances', [[]])[0] for idx, doc in enumerate(documents): chunk_info = { - "content": doc, - "source": metadatas[idx].get( - "source", metadatas[idx].get("name", "Unknown") - ), - "file_id": metadatas[idx].get("file_id", ""), + 'content': doc, + 'source': metadatas[idx].get('source', metadatas[idx].get('name', 'Unknown')), + 'file_id': metadatas[idx].get('file_id', ''), } if idx < len(distances): - chunk_info["distance"] = distances[idx] + chunk_info['distance'] = distances[idx] chunks.append(chunk_info) # Limit to requested count @@ -1929,8 +2162,8 @@ async def query_knowledge_files( return json.dumps(chunks, ensure_ascii=False) except Exception as e: - log.exception(f"query_knowledge_files error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'query_knowledge_files error: {e}') + return json.dumps({'error': str(e)}) async def query_knowledge_bases( @@ -1949,10 +2182,10 @@ async def query_knowledge_bases( :return: JSON with matching KBs (id, name, description, similarity) """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: import heapq @@ -1960,7 +2193,7 @@ async def query_knowledge_bases( from open_webui.routers.knowledge import KNOWLEDGE_BASES_COLLECTION from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT - user_id = __user__.get("id") + user_id = __user__.get('id') user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] query_embedding = await __request__.app.state.EMBEDDING_FUNCTION(query) @@ -1973,7 +2206,7 @@ async def query_knowledge_bases( while True: accessible_knowledge_bases = Knowledges.search_knowledge_bases( user_id, - filter={"user_id": user_id, "group_ids": user_group_ids}, + filter={'user_id': user_id, 'group_ids': user_group_ids}, skip=page_offset, limit=page_size, ) @@ -1986,17 +2219,13 @@ async def query_knowledge_bases( search_results = VECTOR_DB_CLIENT.search( collection_name=KNOWLEDGE_BASES_COLLECTION, vectors=[query_embedding], - filter={"knowledge_base_id": {"$in": accessible_ids}}, + filter={'knowledge_base_id': {'$in': accessible_ids}}, limit=count, ) if search_results and search_results.ids and search_results.ids[0]: result_ids = search_results.ids[0] - result_distances = ( - search_results.distances[0] - if search_results.distances - else [0] * len(result_ids) - ) + result_distances = search_results.distances[0] if search_results.distances else [0] * len(result_ids) for knowledge_base_id, distance in zip(result_ids, result_distances): if knowledge_base_id in seen_ids: @@ -2006,9 +2235,7 @@ async def query_knowledge_bases( if len(top_results_heap) < count: heapq.heappush(top_results_heap, (distance, knowledge_base_id)) elif distance > top_results_heap[0][0]: - heapq.heapreplace( - top_results_heap, (distance, knowledge_base_id) - ) + heapq.heapreplace(top_results_heap, (distance, knowledge_base_id)) page_offset += page_size if len(accessible_knowledge_bases.items) < page_size: @@ -2025,18 +2252,18 @@ async def query_knowledge_bases( if knowledge_base: matching_knowledge_bases.append( { - "id": knowledge_base.id, - "name": knowledge_base.name, - "description": knowledge_base.description or "", - "similarity": round(distance, 4), + 'id': knowledge_base.id, + 'name': knowledge_base.name, + 'description': knowledge_base.description or '', + 'similarity': round(distance, 4), } ) return json.dumps(matching_knowledge_bases, ensure_ascii=False) except Exception as e: - log.exception(f"query_knowledge_bases error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'query_knowledge_bases error: {e}') + return json.dumps({'error': str(e)}) # ============================================================================= @@ -2057,45 +2284,43 @@ async def view_skill( :return: The full skill instructions as markdown content """ if __request__ is None: - return json.dumps({"error": "Request context not available"}) + return json.dumps({'error': 'Request context not available'}) if not __user__: - return json.dumps({"error": "User context not available"}) + return json.dumps({'error': 'User context not available'}) try: from open_webui.models.skills import Skills from open_webui.models.access_grants import AccessGrants - user_id = __user__.get("id") + user_id = __user__.get('id') # Direct DB lookup by unique name skill = Skills.get_skill_by_name(name) if not skill or not skill.is_active: - return json.dumps({"error": f"Skill '{name}' not found"}) + return json.dumps({'error': f"Skill '{name}' not found"}) # Check user access - user_role = __user__.get("role", "user") - if user_role != "admin" and skill.user_id != user_id: - user_group_ids = [ - group.id for group in Groups.get_groups_by_member_id(user_id) - ] + user_role = __user__.get('role', 'user') + if user_role != 'admin' and skill.user_id != user_id: + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] if not AccessGrants.has_access( user_id=user_id, - resource_type="skill", + resource_type='skill', resource_id=skill.id, - permission="read", + permission='read', user_group_ids=set(user_group_ids), ): - return json.dumps({"error": "Access denied"}) + return json.dumps({'error': 'Access denied'}) return json.dumps( { - "name": skill.name, - "content": skill.content, + 'name': skill.name, + 'content': skill.content, }, ensure_ascii=False, ) except Exception as e: - log.exception(f"view_skill error: {e}") - return json.dumps({"error": str(e)}) + log.exception(f'view_skill error: {e}') + return json.dumps({'error': str(e)}) diff --git a/backend/open_webui/utils/access_control/__init__.py b/backend/open_webui/utils/access_control/__init__.py index a228bbd6a8..f31c59e158 100644 --- a/backend/open_webui/utils/access_control/__init__.py +++ b/backend/open_webui/utils/access_control/__init__.py @@ -1,15 +1,20 @@ -from typing import Optional, Set, Union, List, Dict, Any -from open_webui.models.users import Users, UserModel -from open_webui.models.groups import Groups - +import json +from typing import Any +from open_webui.models.users import UserModel +from open_webui.models.groups import Groups +from open_webui.models.access_grants import ( + has_public_read_access_grant, + has_public_write_access_grant, + has_user_access_grant, + strip_user_access_grants, +) from open_webui.config import DEFAULT_USER_PERMISSIONS -import json +from sqlalchemy.orm import Session -def fill_missing_permissions( - permissions: Dict[str, Any], default_permissions: Dict[str, Any] -) -> Dict[str, Any]: + +def fill_missing_permissions(permissions: dict[str, Any], default_permissions: dict[str, Any]) -> dict[str, Any]: """ Recursively fills in missing properties in the permissions dictionary using the default permissions as a template. @@ -17,9 +22,7 @@ def fill_missing_permissions( for key, value in default_permissions.items(): if key not in permissions: permissions[key] = value - elif isinstance(value, dict) and isinstance( - permissions[key], dict - ): # Both are nested dictionaries + elif isinstance(value, dict) and isinstance(permissions[key], dict): # Both are nested dictionaries permissions[key] = fill_missing_permissions(permissions[key], value) return permissions @@ -27,18 +30,16 @@ def fill_missing_permissions( def get_permissions( user_id: str, - default_permissions: Dict[str, Any], - db: Optional[Any] = None, -) -> Dict[str, Any]: + default_permissions: dict[str, Any], + db: Session | None = None, +) -> dict[str, Any]: """ Get all permissions for a user by combining the permissions of all groups the user is a member of. If a permission is defined in multiple groups, the most permissive value is used (True > False). Permissions are nested in a dict with the permission key as the key and a boolean as the value. """ - def combine_permissions( - permissions: Dict[str, Any], group_permissions: Dict[str, Any] - ) -> Dict[str, Any]: + def combine_permissions(permissions: dict[str, Any], group_permissions: dict[str, Any]) -> dict[str, Any]: """Combine permissions from multiple groups by taking the most permissive value.""" for key, value in group_permissions.items(): if isinstance(value, dict): @@ -49,9 +50,7 @@ def combine_permissions( if key not in permissions: permissions[key] = value else: - permissions[key] = ( - permissions[key] or value - ) # Use the most permissive value (True > False) + permissions[key] = permissions[key] or value # Use the most permissive value (True > False) return permissions user_groups = Groups.get_groups_by_member_id(user_id, db=db) @@ -72,8 +71,8 @@ def combine_permissions( def has_permission( user_id: str, permission_key: str, - default_permissions: Dict[str, Any] = {}, - db: Optional[Any] = None, + default_permissions: dict[str, Any] = {}, + db: Session | None = None, ) -> bool: """ Check if a user has a specific permission by checking the group permissions @@ -82,7 +81,7 @@ def has_permission( Permission keys can be hierarchical and separated by dots ('.'). """ - def get_permission(permissions: Dict[str, Any], keys: List[str]) -> bool: + def get_permission(permissions: dict[str, Any], keys: list[str]) -> bool: """Traverse permissions dict using a list of keys (from dot-split permission_key).""" for key in keys: if key not in permissions: @@ -91,7 +90,7 @@ def get_permission(permissions: Dict[str, Any], keys: List[str]) -> bool: return bool(permissions) # Return the boolean at the final level - permission_hierarchy = permission_key.split(".") + permission_hierarchy = permission_key.split('.') # Retrieve user group permissions user_groups = Groups.get_groups_by_member_id(user_id, db=db) @@ -101,18 +100,16 @@ def get_permission(permissions: Dict[str, Any], keys: List[str]) -> bool: return True # Check default permissions afterward if the group permissions don't allow it - default_permissions = fill_missing_permissions( - default_permissions, DEFAULT_USER_PERMISSIONS - ) + default_permissions = fill_missing_permissions(default_permissions, DEFAULT_USER_PERMISSIONS) return get_permission(default_permissions, permission_hierarchy) def has_access( user_id: str, - permission: str = "read", - access_grants: Optional[list] = None, - user_group_ids: Optional[Set[str]] = None, - db: Optional[Any] = None, + permission: str = 'read', + access_grants: list | None = None, + user_group_ids: set[str] | None = None, + db: Session | None = None, ) -> bool: """ Check if a user has the specified permission using an in-memory access_grants list. @@ -135,19 +132,13 @@ def has_access( for grant in access_grants: if not isinstance(grant, dict): continue - if grant.get("permission") != permission: + if grant.get('permission') != permission: continue - principal_type = grant.get("principal_type") - principal_id = grant.get("principal_id") - if principal_type == "user" and ( - principal_id == "*" or principal_id == user_id - ): + principal_type = grant.get('principal_type') + principal_id = grant.get('principal_id') + if principal_type == 'user' and (principal_id == '*' or principal_id == user_id): return True - if ( - principal_type == "group" - and user_group_ids - and principal_id in user_group_ids - ): + if principal_type == 'group' and user_group_ids and principal_id in user_group_ids: return True return False @@ -156,7 +147,7 @@ def has_access( def has_connection_access( user: UserModel, connection: dict, - user_group_ids: Optional[Set[str]] = None, + user_group_ids: set[str] | None = None, ) -> bool: """ Check if a user can access a server connection (tool server, terminal, etc.) @@ -168,19 +159,17 @@ def has_connection_access( """ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL - if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL: return True if user_group_ids is None: user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} - access_grants = (connection.get("config") or {}).get("access_grants", []) - return has_access(user.id, "read", access_grants, user_group_ids) + access_grants = (connection.get('config') or {}).get('access_grants', []) + return has_access(user.id, 'read', access_grants, user_group_ids) -def migrate_access_control( - data: dict, ac_key: str = "access_control", grants_key: str = "access_grants" -) -> None: +def migrate_access_control(data: dict, ac_key: str = 'access_control', grants_key: str = 'access_grants') -> None: """ Auto-migrate a config dict in-place from legacy access_control dict to access_grants list. @@ -194,26 +183,26 @@ def migrate_access_control( if access_control is None and ac_key not in data: return - grants: List[Dict[str, str]] = [] + grants: list[dict[str, str]] = [] if access_control and isinstance(access_control, dict): - for perm in ["read", "write"]: + for perm in ['read', 'write']: perm_data = access_control.get(perm, {}) if not perm_data: continue - for group_id in perm_data.get("group_ids", []): + for group_id in perm_data.get('group_ids', []): grants.append( { - "principal_type": "group", - "principal_id": group_id, - "permission": perm, + 'principal_type': 'group', + 'principal_id': group_id, + 'permission': perm, } ) - for uid in perm_data.get("user_ids", []): + for uid in perm_data.get('user_ids', []): grants.append( { - "principal_type": "user", - "principal_id": uid, - "permission": perm, + 'principal_type': 'user', + 'principal_id': uid, + 'permission': perm, } ) @@ -221,30 +210,25 @@ def migrate_access_control( data.pop(ac_key, None) -from open_webui.models.access_grants import ( - has_public_read_access_grant, - has_user_access_grant, - strip_user_access_grants, -) - - def filter_allowed_access_grants( - default_permissions: Dict[str, Any], + default_permissions: dict[str, Any], user_id: str, user_role: str, access_grants: list, public_permission_key: str, - db: Optional[Any] = None, + db: Session | 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 user_role == 'admin' or not access_grants: return access_grants # Check if user can share publicly - if has_public_read_access_grant(access_grants) and not has_permission( + if ( + has_public_read_access_grant(access_grants) or has_public_write_access_grant(access_grants) + ) and not has_permission( user_id, public_permission_key, default_permissions, @@ -254,25 +238,17 @@ def filter_allowed_access_grants( grant for grant in access_grants if not ( - ( - grant.get("principal_type") - if isinstance(grant, dict) - else getattr(grant, "principal_type", None) - ) - == "user" - and ( - grant.get("principal_id") - if isinstance(grant, dict) - else getattr(grant, "principal_id", None) - ) - == "*" + (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)) + == '*' ) ] # Strip individual user sharing if user lacks permission if has_user_access_grant(access_grants) and not has_permission( user_id, - "access_grants.allow_users", + 'access_grants.allow_users', default_permissions, db=db, ): diff --git a/backend/open_webui/utils/access_control/files.py b/backend/open_webui/utils/access_control/files.py index e3d52b0f55..a7e35fd506 100644 --- a/backend/open_webui/utils/access_control/files.py +++ b/backend/open_webui/utils/access_control/files.py @@ -1,5 +1,4 @@ import logging -from typing import Optional, Any from open_webui.models.users import UserModel from open_webui.models.files import Files @@ -10,14 +9,16 @@ from open_webui.models.models import Models from open_webui.models.access_grants import AccessGrants +from sqlalchemy.orm import Session + log = logging.getLogger(__name__) def has_access_to_file( - file_id: Optional[str], + file_id: str | None, access_type: str, user: UserModel, - db: Optional[Any] = None, + db: Session | None = None, ) -> bool: """ Check if a user has the specified access to a file through any of: @@ -30,7 +31,7 @@ def has_access_to_file( file.user_id == user.id separately before calling this. """ file = Files.get_file_by_id(file_id, db=db) - log.debug(f"Checking if user has {access_type} access to file") + log.debug(f'Checking if user has {access_type} access to file') if not file: return False @@ -40,13 +41,11 @@ def has_access_to_file( # Check if the file is associated with any knowledge bases the user has access to knowledge_bases = Knowledges.get_knowledges_by_file_id(file_id, db=db) - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} for knowledge_base in knowledge_bases: if knowledge_base.user_id == user.id or AccessGrants.has_access( user_id=user.id, - resource_type="knowledge", + resource_type='knowledge', resource_id=knowledge_base.id, permission=access_type, user_group_ids=user_group_ids, @@ -54,18 +53,16 @@ def has_access_to_file( ): return True - knowledge_base_id = file.meta.get("collection_name") if file.meta else None + knowledge_base_id = file.meta.get('collection_name') if file.meta else None if knowledge_base_id: - knowledge_bases = Knowledges.get_knowledge_bases_by_user_id( - user.id, access_type, db=db - ) + knowledge_bases = 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: return True # Check if the file is associated with any channels the user has access to channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db) - if access_type == "read" and channels: + if access_type == 'read' and channels: return True # Check if the file is associated with any chats the user has access to @@ -76,13 +73,9 @@ def has_access_to_file( # Check if the file is directly attached to a shared workspace model for model in Models.get_models_by_user_id(user.id, permission=access_type, db=db): - knowledge_items = getattr(model.meta, "knowledge", None) or [] + 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 - ): + if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id: return True return False diff --git a/backend/open_webui/utils/actions.py b/backend/open_webui/utils/actions.py index 0b4b817f0a..5c5712fa0f 100644 --- a/backend/open_webui/utils/actions.py +++ b/backend/open_webui/utils/actions.py @@ -21,70 +21,70 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: Any): - if "." in action_id: - action_id, sub_action_id = action_id.split(".") + if '.' in action_id: + action_id, sub_action_id = action_id.split('.') else: sub_action_id = None action = Functions.get_function_by_id(action_id) if not action: - raise Exception(f"Action not found: {action_id}") + raise Exception(f'Action not found: {action_id}') if not request.app.state.MODELS: await get_all_models(request, user=user) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS data = form_data - model_id = data["model"] + model_id = data['model'] if model_id not in models: - raise Exception("Model not found") + raise Exception('Model not found') model = models[model_id] __event_emitter__ = get_event_emitter( { - "chat_id": data["chat_id"], - "message_id": data["id"], - "session_id": data["session_id"], - "user_id": user.id, + 'chat_id': data['chat_id'], + 'message_id': data['id'], + 'session_id': data['session_id'], + 'user_id': user.id, } ) __event_call__ = get_event_call( { - "chat_id": data["chat_id"], - "message_id": data["id"], - "session_id": data["session_id"], - "user_id": user.id, + 'chat_id': data['chat_id'], + 'message_id': data['id'], + 'session_id': data['session_id'], + 'user_id': user.id, } ) function_module, _, _ = get_function_module_from_cache(request, action_id) - if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): + if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'): valves = Functions.get_function_valves_by_id(action_id) function_module.valves = function_module.Valves(**(valves if valves else {})) - if hasattr(function_module, "action"): + if hasattr(function_module, 'action'): try: action = function_module.action # Get the signature of the function sig = inspect.signature(action) - params = {"body": data} + params = {'body': data} # Extra parameters to be passed to the function extra_params = { - "__model__": model, - "__id__": sub_action_id if sub_action_id is not None else action_id, - "__event_emitter__": __event_emitter__, - "__event_call__": __event_call__, - "__request__": request, + '__model__': model, + '__id__': sub_action_id if sub_action_id is not None else action_id, + '__event_emitter__': __event_emitter__, + '__event_call__': __event_call__, + '__request__': request, } # Add extra params in contained in function signature @@ -92,20 +92,18 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A if key in sig.parameters: params[key] = value - if "__user__" in sig.parameters: + if '__user__' in sig.parameters: __user__ = user.model_dump() if isinstance(user, UserModel) else {} try: - if hasattr(function_module, "UserValves"): - __user__["valves"] = function_module.UserValves( - **Functions.get_user_valves_by_id_and_user_id( - action_id, user.id - ) + if hasattr(function_module, 'UserValves'): + __user__['valves'] = function_module.UserValves( + **Functions.get_user_valves_by_id_and_user_id(action_id, user.id) ) except Exception as e: - log.exception(f"Failed to get user values: {e}") + log.exception(f'Failed to get user values: {e}') - params = {**params, "__user__": __user__} + params = {**params, '__user__': __user__} if inspect.iscoroutinefunction(action): data = await action(**params) @@ -117,15 +115,15 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A request, action_id, data, - "action", + 'action', ) if action_embeds: await __event_emitter__( { - "type": "embeds", - "data": { - "embeds": action_embeds, + 'type': 'embeds', + 'data': { + 'embeds': action_embeds, }, } ) @@ -134,6 +132,6 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A data = processed_result except Exception as e: - raise Exception(f"Error: {e}") + raise Exception(f'Error: {e}') return data diff --git a/backend/open_webui/utils/anthropic.py b/backend/open_webui/utils/anthropic.py index 736f7238bc..5ba4099fb4 100644 --- a/backend/open_webui/utils/anthropic.py +++ b/backend/open_webui/utils/anthropic.py @@ -16,7 +16,7 @@ def is_anthropic_url(url: str) -> bool: """Check if the URL is an Anthropic API endpoint.""" - return "api.anthropic.com" in url + return 'api.anthropic.com' in url async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> dict: @@ -31,56 +31,56 @@ async def get_anthropic_models(url: str, key: str, user: UserModel = None) -> di try: async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: headers = { - "x-api-key": key, - "anthropic-version": "2023-06-01", + 'x-api-key': key, + 'anthropic-version': '2023-06-01', } if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) while True: - params = {"limit": 1000} + params = {'limit': 1000} if after_id: - params["after_id"] = after_id + params['after_id'] = after_id async with session.get( - f"{url}/models", + f'{url}/models', headers=headers, params=params, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: if response.status != 200: - error_detail = f"HTTP Error: {response.status}" + error_detail = f'HTTP Error: {response.status}' try: res = await response.json() - if "error" in res: - error_detail = f"External Error: {res['error']}" + if 'error' in res: + error_detail = f'External Error: {res["error"]}' except Exception: pass - return {"object": "list", "data": [], "error": error_detail} + return {'object': 'list', 'data': [], 'error': error_detail} data = await response.json() - for model in data.get("data", []): + for model in data.get('data', []): all_models.append( { - "id": model.get("id"), - "object": "model", - "created": 0, - "owned_by": "anthropic", - "name": model.get("display_name", model.get("id")), + 'id': model.get('id'), + 'object': 'model', + 'created': 0, + 'owned_by': 'anthropic', + 'name': model.get('display_name', model.get('id')), } ) - if not data.get("has_more", False): + if not data.get('has_more', False): break - after_id = data.get("last_id") + after_id = data.get('last_id') except Exception as e: - log.error(f"Anthropic connection error: {e}") + log.error(f'Anthropic connection error: {e}') return None - return {"object": "list", "data": all_models} + return {'object': 'list', 'data': all_models} ############################## @@ -102,245 +102,241 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict: openai_payload = {} # Model - openai_payload["model"] = anthropic_payload.get("model", "") + openai_payload['model'] = anthropic_payload.get('model', '') # Build messages list messages = [] # System prompt (Anthropic has it as top-level, OpenAI as a system message) - system = anthropic_payload.get("system") + system = anthropic_payload.get('system') if system: if isinstance(system, str): - messages.append({"role": "system", "content": system}) + messages.append({'role': 'system', 'content': system}) elif isinstance(system, list): # Anthropic supports system as list of content blocks text_parts = [] for block in system: - if isinstance(block, dict) and block.get("type") == "text": - text_parts.append(block.get("text", "")) + if isinstance(block, dict) and block.get('type') == 'text': + text_parts.append(block.get('text', '')) elif isinstance(block, str): text_parts.append(block) - messages.append({"role": "system", "content": "\n".join(text_parts)}) + messages.append({'role': 'system', 'content': '\n'.join(text_parts)}) # Convert messages - for msg in anthropic_payload.get("messages", []): - role = msg.get("role", "user") - content = msg.get("content") + for msg in anthropic_payload.get('messages', []): + role = msg.get('role', 'user') + content = msg.get('content') if isinstance(content, str): - messages.append({"role": role, "content": content}) + messages.append({'role': role, 'content': content}) elif isinstance(content, list): # Convert Anthropic content blocks to OpenAI format openai_content = [] tool_calls = [] for block in content: - block_type = block.get("type", "text") + block_type = block.get('type', 'text') - if block_type == "text": + if block_type == 'text': openai_content.append( { - "type": "text", - "text": block.get("text", ""), + 'type': 'text', + 'text': block.get('text', ''), } ) - elif block_type == "image": - source = block.get("source", {}) - if source.get("type") == "base64": - media_type = source.get("media_type", "image/png") - data = source.get("data", "") + elif block_type == 'image': + source = block.get('source', {}) + if source.get('type') == 'base64': + media_type = source.get('media_type', 'image/png') + data = source.get('data', '') openai_content.append( { - "type": "image_url", - "image_url": { - "url": f"data:{media_type};base64,{data}", + 'type': 'image_url', + 'image_url': { + 'url': f'data:{media_type};base64,{data}', }, } ) - elif source.get("type") == "url": + elif source.get('type') == 'url': openai_content.append( { - "type": "image_url", - "image_url": {"url": source.get("url", "")}, + 'type': 'image_url', + 'image_url': {'url': source.get('url', '')}, } ) - elif block_type == "tool_use": + elif block_type == 'tool_use': tool_calls.append( { - "id": block.get("id", ""), - "type": "function", - "function": { - "name": block.get("name", ""), - "arguments": ( - json.dumps(block.get("input", {})) - if isinstance(block.get("input"), dict) - else str(block.get("input", "{}")) + 'id': block.get('id', ''), + 'type': 'function', + 'function': { + 'name': block.get('name', ''), + 'arguments': ( + json.dumps(block.get('input', {})) + if isinstance(block.get('input'), dict) + else str(block.get('input', '{}')) ), }, } ) - elif block_type == "tool_result": + elif block_type == 'tool_result': # Tool results become separate tool messages in OpenAI format - tool_content = block.get("content", "") + tool_content = block.get('content', '') if isinstance(tool_content, list): tool_text_parts = [] for tc in tool_content: - if isinstance(tc, dict) and tc.get("type") == "text": - tool_text_parts.append(tc.get("text", "")) - tool_content = "\n".join(tool_text_parts) + if isinstance(tc, dict) and tc.get('type') == 'text': + tool_text_parts.append(tc.get('text', '')) + tool_content = '\n'.join(tool_text_parts) # Propagate error status if present - if block.get("is_error"): - tool_content = f"Error: {tool_content}" + if block.get('is_error'): + tool_content = f'Error: {tool_content}' messages.append( { - "role": "tool", - "tool_call_id": block.get("tool_use_id", ""), - "content": tool_content, + 'role': 'tool', + 'tool_call_id': block.get('tool_use_id', ''), + 'content': tool_content, } ) # Build the message if tool_calls: # Assistant message with tool calls - msg_dict = {"role": role} + msg_dict = {'role': role} if openai_content: # If there's only text, flatten it - if len(openai_content) == 1 and openai_content[0]["type"] == "text": - msg_dict["content"] = openai_content[0]["text"] + if len(openai_content) == 1 and openai_content[0]['type'] == 'text': + msg_dict['content'] = openai_content[0]['text'] else: - msg_dict["content"] = openai_content + msg_dict['content'] = openai_content else: - msg_dict["content"] = "" - msg_dict["tool_calls"] = tool_calls + msg_dict['content'] = '' + msg_dict['tool_calls'] = tool_calls messages.append(msg_dict) elif openai_content: # If there's only a single text block, flatten it to a string - if len(openai_content) == 1 and openai_content[0]["type"] == "text": - messages.append( - {"role": role, "content": openai_content[0]["text"]} - ) + if len(openai_content) == 1 and openai_content[0]['type'] == 'text': + messages.append({'role': role, 'content': openai_content[0]['text']}) else: - messages.append({"role": role, "content": openai_content}) + messages.append({'role': role, 'content': openai_content}) else: - messages.append({"role": role, "content": str(content) if content else ""}) + messages.append({'role': role, 'content': str(content) if content else ''}) - openai_payload["messages"] = messages + openai_payload['messages'] = messages # max_tokens - if "max_tokens" in anthropic_payload: - openai_payload["max_tokens"] = anthropic_payload["max_tokens"] + if 'max_tokens' in anthropic_payload: + openai_payload['max_tokens'] = anthropic_payload['max_tokens'] # Common parameters - for param in ("temperature", "top_p", "stop_sequences", "stream"): + for param in ('temperature', 'top_p', 'stop_sequences', 'stream'): if param in anthropic_payload: - if param == "stop_sequences": - openai_payload["stop"] = anthropic_payload[param] + if param == 'stop_sequences': + openai_payload['stop'] = anthropic_payload[param] else: openai_payload[param] = anthropic_payload[param] # Tools conversion: Anthropic โ†’ OpenAI - if "tools" in anthropic_payload: + if 'tools' in anthropic_payload: openai_tools = [] - for tool in anthropic_payload["tools"]: + for tool in anthropic_payload['tools']: openai_tools.append( { - "type": "function", - "function": { - "name": tool.get("name", ""), - "description": tool.get("description", ""), - "parameters": tool.get("input_schema", {}), + 'type': 'function', + 'function': { + 'name': tool.get('name', ''), + 'description': tool.get('description', ''), + 'parameters': tool.get('input_schema', {}), }, } ) - openai_payload["tools"] = openai_tools + openai_payload['tools'] = openai_tools # tool_choice - if "tool_choice" in anthropic_payload: - tc = anthropic_payload["tool_choice"] + if 'tool_choice' in anthropic_payload: + tc = anthropic_payload['tool_choice'] if isinstance(tc, dict): - tc_type = tc.get("type", "auto") - if tc_type == "auto": - openai_payload["tool_choice"] = "auto" - elif tc_type == "any": - openai_payload["tool_choice"] = "required" - elif tc_type == "tool": - openai_payload["tool_choice"] = { - "type": "function", - "function": {"name": tc.get("name", "")}, + tc_type = tc.get('type', 'auto') + if tc_type == 'auto': + openai_payload['tool_choice'] = 'auto' + elif tc_type == 'any': + openai_payload['tool_choice'] = 'required' + elif tc_type == 'tool': + openai_payload['tool_choice'] = { + 'type': 'function', + 'function': {'name': tc.get('name', '')}, } 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 = '') -> dict: """ Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format. """ import uuid as _uuid choice = {} - if openai_response.get("choices"): - choice = openai_response["choices"][0] + if openai_response.get('choices'): + choice = openai_response['choices'][0] - message = choice.get("message", {}) - finish_reason = choice.get("finish_reason", "stop") + message = choice.get('message', {}) + finish_reason = choice.get('finish_reason', 'stop') # Map finish_reason to stop_reason stop_reason_map = { - "stop": "end_turn", - "length": "max_tokens", - "tool_calls": "tool_use", - "content_filter": "end_turn", + 'stop': 'end_turn', + 'length': 'max_tokens', + 'tool_calls': 'tool_use', + 'content_filter': 'end_turn', } - stop_reason = stop_reason_map.get(finish_reason, "end_turn") + stop_reason = stop_reason_map.get(finish_reason, 'end_turn') # Build content blocks content = [] - msg_content = message.get("content") + msg_content = message.get('content') if msg_content: - content.append({"type": "text", "text": msg_content}) + content.append({'type': 'text', 'text': msg_content}) # Tool calls โ†’ tool_use blocks - tool_calls = message.get("tool_calls", []) + tool_calls = message.get('tool_calls', []) for tc in tool_calls: - func = tc.get("function", {}) + func = tc.get('function', {}) try: - tool_input = json.loads(func.get("arguments", "{}")) + tool_input = json.loads(func.get('arguments', '{}')) except (json.JSONDecodeError, TypeError): tool_input = {} content.append( { - "type": "tool_use", - "id": tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}"), - "name": func.get("name", ""), - "input": tool_input, + 'type': 'tool_use', + 'id': tc.get('id', f'toolu_{_uuid.uuid4().hex[:24]}'), + 'name': func.get('name', ''), + 'input': tool_input, } ) # Usage - openai_usage = openai_response.get("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': openai_usage.get('prompt_tokens', 0), + 'output_tokens': openai_usage.get('completion_tokens', 0), } return { - "id": openai_response.get("id", f"msg_{_uuid.uuid4().hex[:24]}"), - "type": "message", - "role": "assistant", - "content": content, - "model": model or openai_response.get("model", ""), - "stop_reason": stop_reason, - "stop_sequence": None, - "usage": usage, + 'id': openai_response.get('id', f'msg_{_uuid.uuid4().hex[:24]}'), + 'type': 'message', + 'role': 'assistant', + 'content': content, + 'model': model or openai_response.get('model', ''), + 'stop_reason': stop_reason, + 'stop_sequence': None, + 'usage': usage, } -async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ""): +async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ''): """ Convert an OpenAI SSE streaming response to Anthropic Messages SSE format. @@ -352,10 +348,10 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str """ import uuid as _uuid - msg_id = f"msg_{_uuid.uuid4().hex[:24]}" + msg_id = f'msg_{_uuid.uuid4().hex[:24]}' input_tokens = 0 output_tokens = 0 - stop_reason = "end_turn" + stop_reason = 'end_turn' # Track content blocks with a running index. # Each text block or tool_use block gets its own index. @@ -369,35 +365,35 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str # Emit message_start message_start = { - "type": "message_start", - "message": { - "id": msg_id, - "type": "message", - "role": "assistant", - "content": [], - "model": model, - "stop_reason": None, - "stop_sequence": None, - "usage": {"input_tokens": 0, "output_tokens": 0}, + 'type': 'message_start', + 'message': { + 'id': msg_id, + 'type': 'message', + 'role': 'assistant', + 'content': [], + 'model': model, + 'stop_reason': None, + 'stop_sequence': None, + 'usage': {'input_tokens': 0, 'output_tokens': 0}, }, } - yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n".encode() + yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode() try: async for chunk in openai_stream_generator: if isinstance(chunk, bytes): - chunk = chunk.decode("utf-8", errors="ignore") + chunk = chunk.decode('utf-8', errors='ignore') - for line in chunk.strip().split("\n"): + for line in chunk.strip().split('\n'): line = line.strip() - if not line or not line.startswith("data:"): + if not line or not line.startswith('data:'): continue data_str = line[5:].strip() - if data_str == "[DONE]": + if data_str == '[DONE]': continue - if data_str == "{}": + if data_str == '{}': continue try: @@ -405,62 +401,58 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str except (json.JSONDecodeError, TypeError): continue - choices = data.get("choices", []) + choices = data.get('choices', []) 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 - ) + if data.get('usage'): + input_tokens = data['usage'].get('prompt_tokens', input_tokens) + output_tokens = data['usage'].get('completion_tokens', output_tokens) continue - delta = choices[0].get("delta", {}) - finish_reason = choices[0].get("finish_reason") + delta = choices[0].get('delta', {}) + finish_reason = choices[0].get('finish_reason') # 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 - ) + if data.get('usage'): + input_tokens = data['usage'].get('prompt_tokens', input_tokens) + output_tokens = data['usage'].get('completion_tokens', output_tokens) # --- Handle text content --- - content = delta.get("content") + content = delta.get('content') if content is not None: if not text_block_open: # Start a new text content block block_start = { - "type": "content_block_start", - "index": current_block_index, - "content_block": {"type": "text", "text": ""}, + 'type': 'content_block_start', + 'index': current_block_index, + 'content_block': {'type': 'text', 'text': ''}, } - yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode() + yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode() text_block_open = True # Send text delta block_delta = { - "type": "content_block_delta", - "index": current_block_index, - "delta": {"type": "text_delta", "text": content}, + 'type': 'content_block_delta', + 'index': current_block_index, + 'delta': {'type': 'text_delta', 'text': content}, } - yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode() + yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode() # --- Handle tool calls --- - tool_calls = delta.get("tool_calls") + tool_calls = delta.get('tool_calls') if tool_calls: # Close text block if one is open (text comes before tools) if text_block_open: block_stop = { - "type": "content_block_stop", - "index": current_block_index, + 'type': 'content_block_stop', + 'index': current_block_index, } - yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode() + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() text_block_open = False current_block_index += 1 for tc in tool_calls: - tc_index = tc.get("index", 0) + tc_index = tc.get('index', 0) if tc_index not in tool_call_started: # First time seeing this tool call โ€” emit content_block_start @@ -468,67 +460,67 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str tool_call_started[tc_index] = True # Extract tool call ID and name from the first chunk - tc_id = tc.get("id", f"toolu_{_uuid.uuid4().hex[:24]}") - tc_name = tc.get("function", {}).get("name", "") + tc_id = tc.get('id', f'toolu_{_uuid.uuid4().hex[:24]}') + tc_name = tc.get('function', {}).get('name', '') block_start = { - "type": "content_block_start", - "index": current_block_index, - "content_block": { - "type": "tool_use", - "id": tc_id, - "name": tc_name, - "input": {}, + 'type': 'content_block_start', + 'index': current_block_index, + 'content_block': { + 'type': 'tool_use', + 'id': tc_id, + 'name': tc_name, + 'input': {}, }, } - yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n".encode() + yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode() current_block_index += 1 # Emit argument chunks as input_json_delta - args_chunk = tc.get("function", {}).get("arguments", "") + args_chunk = tc.get('function', {}).get('arguments', '') if args_chunk: block_delta = { - "type": "content_block_delta", - "index": tool_call_blocks[tc_index], - "delta": { - "type": "input_json_delta", - "partial_json": args_chunk, + 'type': 'content_block_delta', + 'index': tool_call_blocks[tc_index], + 'delta': { + 'type': 'input_json_delta', + 'partial_json': args_chunk, }, } - yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n".encode() + yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode() # --- Handle finish reason --- if finish_reason is not None: stop_reason_map = { - "stop": "end_turn", - "length": "max_tokens", - "tool_calls": "tool_use", + 'stop': 'end_turn', + 'length': 'max_tokens', + 'tool_calls': 'tool_use', } - stop_reason = stop_reason_map.get(finish_reason, "end_turn") + stop_reason = stop_reason_map.get(finish_reason, 'end_turn') except Exception as e: - log.error(f"Error in Anthropic stream conversion: {e}") + log.error(f'Error in Anthropic stream conversion: {e}') # Close any open text block if text_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() + block_stop = {'type': 'content_block_stop', 'index': current_block_index} + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() # Close any open tool call blocks for tc_index, block_index in tool_call_blocks.items(): - block_stop = {"type": "content_block_stop", "index": block_index} - yield f"event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n".encode() + block_stop = {'type': 'content_block_stop', 'index': block_index} + yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode() # Emit message_delta with stop reason message_delta = { - "type": "message_delta", - "delta": { - "stop_reason": stop_reason, - "stop_sequence": None, + 'type': 'message_delta', + 'delta': { + 'stop_reason': stop_reason, + 'stop_sequence': None, }, - "usage": {"output_tokens": output_tokens}, + 'usage': {'output_tokens': output_tokens}, } - yield f"event: message_delta\ndata: {json.dumps(message_delta)}\n\n".encode() + yield f'event: message_delta\ndata: {json.dumps(message_delta)}\n\n'.encode() # Emit message_stop - yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n".encode() + yield f'event: message_stop\ndata: {json.dumps({"type": "message_stop"})}\n\n'.encode() diff --git a/backend/open_webui/utils/audit.py b/backend/open_webui/utils/audit.py index c4abb445b9..1200d813af 100644 --- a/backend/open_webui/utils/audit.py +++ b/backend/open_webui/utils/audit.py @@ -24,7 +24,7 @@ from loguru import logger from starlette.requests import Request -from open_webui.env import AUDIT_LOG_LEVEL, MAX_BODY_LOG_SIZE +from open_webui.env import AUDIT_LOG_LEVEL, AUDIT_INCLUDED_PATHS, MAX_BODY_LOG_SIZE from open_webui.utils.auth import get_current_user, get_http_authorization_cred from open_webui.models.users import UserModel @@ -50,10 +50,10 @@ class AuditLogEntry: class AuditLevel(str, Enum): - NONE = "NONE" - METADATA = "METADATA" - REQUEST = "REQUEST" - REQUEST_RESPONSE = "REQUEST_RESPONSE" + NONE = 'NONE' + METADATA = 'METADATA' + REQUEST = 'REQUEST' + REQUEST_RESPONSE = 'REQUEST_RESPONSE' class AuditLogger: @@ -64,25 +64,24 @@ class AuditLogger: logger (Logger): An instance of Loguruโ€™s logger. """ - def __init__(self, logger: "Logger"): + def __init__(self, logger: 'Logger'): self.logger = logger.bind(auditable=True) def write( self, audit_entry: AuditLogEntry, *, - log_level: str = "INFO", + log_level: str = 'INFO', extra: Optional[dict] = None, ): - entry = asdict(audit_entry) if extra: - entry["extra"] = extra + entry['extra'] = extra self.logger.log( log_level, - "", + '', **entry, ) @@ -106,15 +105,11 @@ def __init__(self, max_body_size: int = MAX_BODY_LOG_SIZE): def add_request_chunk(self, chunk: bytes): if len(self.request_body) < self.max_body_size: - self.request_body.extend( - chunk[: self.max_body_size - len(self.request_body)] - ) + self.request_body.extend(chunk[: self.max_body_size - len(self.request_body)]) def add_response_chunk(self, chunk: bytes): if len(self.response_body) < self.max_body_size: - self.response_body.extend( - chunk[: self.max_body_size - len(self.response_body)] - ) + self.response_body.extend(chunk[: self.max_body_size - len(self.response_body)]) class AuditLoggingMiddleware: @@ -122,29 +117,37 @@ class AuditLoggingMiddleware: ASGI middleware that intercepts HTTP requests and responses to perform audit logging. It captures request/response bodies (depending on audit level), headers, HTTP methods, and user information, then logs a structured audit entry at the end of the request cycle. """ - AUDITED_METHODS = {"PUT", "PATCH", "DELETE", "POST"} + AUDITED_METHODS = {'PUT', 'PATCH', 'DELETE', 'POST'} def __init__( self, app: ASGI3Application, *, excluded_paths: Optional[list[str]] = None, + included_paths: Optional[list[str]] = None, max_body_size: int = MAX_BODY_LOG_SIZE, audit_level: AuditLevel = AuditLevel.NONE, ) -> None: self.app = app self.audit_logger = AuditLogger(logger) self.excluded_paths = excluded_paths or [] + self.included_paths = included_paths or [] self.max_body_size = max_body_size self.audit_level = audit_level + if self.included_paths and self.excluded_paths: + logger.warning( + 'Both AUDIT_INCLUDED_PATHS and AUDIT_EXCLUDED_PATHS are set. ' + 'AUDIT_INCLUDED_PATHS (whitelist) takes precedence.' + ) + async def __call__( self, scope: ASGIScope, receive: ASGIReceiveCallable, send: ASGISendCallable, ) -> None: - if scope["type"] != "http": + if scope['type'] != 'http': return await self.app(scope, receive, send) request = Request(scope=cast(MutableMapping, scope)) @@ -177,9 +180,7 @@ async def receive_wrapper() -> ASGIReceiveEvent: await self.app(scope, receive_wrapper, send_wrapper) @asynccontextmanager - async def _audit_context( - self, request: Request - ) -> AsyncGenerator[AuditContext, None]: + async def _audit_context(self, request: Request) -> AsyncGenerator[AuditContext, None]: """ async context manager that ensures that an audit log entry is recorded after the request is processed. """ @@ -190,29 +191,24 @@ async def _audit_context( await self._log_audit_entry(request, context) async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]: - auth_header = request.headers.get("Authorization") + auth_header = request.headers.get('Authorization') try: - user = await get_current_user( - request, None, None, get_http_authorization_cred(auth_header) - ) + user = await get_current_user(request, None, None, get_http_authorization_cred(auth_header)) return user except Exception as e: - logger.debug(f"Failed to get authenticated user: {str(e)}") + logger.debug(f'Failed to get authenticated user: {str(e)}') return None def _should_skip_auditing(self, request: Request) -> bool: - if ( - request.method not in {"POST", "PUT", "PATCH", "DELETE"} - or AUDIT_LOG_LEVEL == "NONE" - ): + if request.method not in {'POST', 'PUT', 'PATCH', 'DELETE'} or AUDIT_LOG_LEVEL == 'NONE': return True ALWAYS_LOG_ENDPOINTS = { - "/api/v1/auths/signin", - "/api/v1/auths/signout", - "/api/v1/auths/signup", + '/api/v1/auths/signin', + '/api/v1/auths/signout', + '/api/v1/auths/signup', } path = request.url.path.lower() for endpoint in ALWAYS_LOG_ENDPOINTS: @@ -221,46 +217,47 @@ def _should_skip_auditing(self, request: Request) -> bool: # Skip logging if the request is not authenticated # Check both Authorization header (API keys) and token cookie (browser sessions) - if not request.headers.get("authorization") and not request.cookies.get( - "token" - ): + if not request.headers.get('authorization') and not request.cookies.get('token'): return True - # match either /api//...(for the endpoint /api/chat case) or /api/v1//... - pattern = re.compile( - r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b" - ) + # 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 + + # 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): return True return False async def _capture_request(self, message: ASGIReceiveEvent, context: AuditContext): - if message["type"] == "http.request": - body = message.get("body", b"") + if message['type'] == 'http.request': + body = message.get('body', b'') context.add_request_chunk(body) async def _capture_response(self, message: ASGISendEvent, context: AuditContext): - if message["type"] == "http.response.start": - context.metadata["response_status_code"] = message["status"] + if message['type'] == 'http.response.start': + context.metadata['response_status_code'] = message['status'] - elif message["type"] == "http.response.body": - body = message.get("body", b"") + elif message['type'] == 'http.response.body': + body = message.get('body', b'') context.add_response_chunk(body) async def _log_audit_entry(self, request: Request, context: AuditContext): try: user = await self._get_authenticated_user(request) - user = ( - user.model_dump(include={"id", "name", "email", "role"}) if user else {} - ) + user = user.model_dump(include={'id', 'name', 'email', 'role'}) if user else {} - request_body = context.request_body.decode("utf-8", errors="replace") - response_body = context.response_body.decode("utf-8", errors="replace") + request_body = context.request_body.decode('utf-8', errors='replace') + response_body = context.response_body.decode('utf-8', errors='replace') # Redact sensitive information - if "password" in request_body: + if 'password' in request_body: request_body = re.sub( r'"password":\s*"(.*?)"', '"password": "********"', @@ -273,13 +270,13 @@ async def _log_audit_entry(self, request: Request, context: AuditContext): audit_level=self.audit_level.value, verb=request.method, request_uri=str(request.url), - response_status_code=context.metadata.get("response_status_code", None), + response_status_code=context.metadata.get('response_status_code', None), source_ip=request.client.host if request.client else None, - user_agent=request.headers.get("user-agent"), + user_agent=request.headers.get('user-agent'), request_object=request_body, response_object=response_body, ) self.audit_logger.write(entry) except Exception as e: - logger.error(f"Failed to log audit entry: {str(e)}") + logger.error(f'Failed to log audit entry: {str(e)}') diff --git a/backend/open_webui/utils/auth.py b/backend/open_webui/utils/auth.py index 67ebb4956b..71bb52565f 100644 --- a/backend/open_webui/utils/auth.py +++ b/backend/open_webui/utils/auth.py @@ -60,7 +60,7 @@ log = logging.getLogger(__name__) SESSION_SECRET = WEBUI_SECRET_KEY -ALGORITHM = "HS256" +ALGORITHM = 'HS256' ############## @@ -88,89 +88,57 @@ def override_static(path: str, content: str): os.makedirs(os.path.dirname(path), exist_ok=True) r = requests.get(content, stream=True) - with open(path, "wb") as f: + with open(path, 'wb') as f: r.raw.decode_content = True shutil.copyfileobj(r.raw, f) def get_license_data(app, key): payload = { - "resources": { - os.path.join(STATIC_DIR, "logo.png"): os.getenv("CUSTOM_PNG", ""), - os.path.join(STATIC_DIR, "favicon.png"): os.getenv("CUSTOM_PNG", ""), - os.path.join(STATIC_DIR, "favicon.svg"): os.getenv("CUSTOM_SVG", ""), - os.path.join(STATIC_DIR, "favicon-96x96.png"): os.getenv("CUSTOM_PNG", ""), - os.path.join(STATIC_DIR, "apple-touch-icon.png"): os.getenv( - "CUSTOM_PNG", "" - ), - os.path.join(STATIC_DIR, "web-app-manifest-192x192.png"): os.getenv( - "CUSTOM_PNG", "" - ), - os.path.join(STATIC_DIR, "web-app-manifest-512x512.png"): os.getenv( - "CUSTOM_PNG", "" - ), - os.path.join(STATIC_DIR, "splash.png"): os.getenv("CUSTOM_PNG", ""), - os.path.join(STATIC_DIR, "favicon.ico"): os.getenv("CUSTOM_ICO", ""), - os.path.join(STATIC_DIR, "favicon-dark.png"): os.getenv( - "CUSTOM_DARK_PNG", "" - ), - os.path.join(STATIC_DIR, "splash-dark.png"): os.getenv( - "CUSTOM_DARK_PNG", "" - ), - os.path.join(FRONTEND_BUILD_DIR, "favicon.png"): os.getenv( - "CUSTOM_PNG", "" - ), - os.path.join(FRONTEND_BUILD_DIR, "static/favicon.png"): os.getenv( - "CUSTOM_PNG", "" - ), - os.path.join(FRONTEND_BUILD_DIR, "static/favicon.svg"): os.getenv( - "CUSTOM_SVG", "" - ), - os.path.join(FRONTEND_BUILD_DIR, "static/favicon-96x96.png"): os.getenv( - "CUSTOM_PNG", "" - ), - os.path.join(FRONTEND_BUILD_DIR, "static/apple-touch-icon.png"): os.getenv( - "CUSTOM_PNG", "" - ), - os.path.join( - FRONTEND_BUILD_DIR, "static/web-app-manifest-192x192.png" - ): os.getenv("CUSTOM_PNG", ""), - os.path.join( - FRONTEND_BUILD_DIR, "static/web-app-manifest-512x512.png" - ): os.getenv("CUSTOM_PNG", ""), - os.path.join(FRONTEND_BUILD_DIR, "static/splash.png"): os.getenv( - "CUSTOM_PNG", "" - ), - os.path.join(FRONTEND_BUILD_DIR, "static/favicon.ico"): os.getenv( - "CUSTOM_ICO", "" - ), - os.path.join(FRONTEND_BUILD_DIR, "static/favicon-dark.png"): os.getenv( - "CUSTOM_DARK_PNG", "" - ), - os.path.join(FRONTEND_BUILD_DIR, "static/splash-dark.png"): os.getenv( - "CUSTOM_DARK_PNG", "" - ), + 'resources': { + os.path.join(STATIC_DIR, 'logo.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(STATIC_DIR, 'favicon.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(STATIC_DIR, 'favicon.svg'): os.getenv('CUSTOM_SVG', ''), + os.path.join(STATIC_DIR, 'favicon-96x96.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(STATIC_DIR, 'apple-touch-icon.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(STATIC_DIR, 'web-app-manifest-192x192.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(STATIC_DIR, 'web-app-manifest-512x512.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(STATIC_DIR, 'splash.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(STATIC_DIR, 'favicon.ico'): os.getenv('CUSTOM_ICO', ''), + os.path.join(STATIC_DIR, 'favicon-dark.png'): os.getenv('CUSTOM_DARK_PNG', ''), + os.path.join(STATIC_DIR, 'splash-dark.png'): os.getenv('CUSTOM_DARK_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'favicon.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/favicon.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/favicon.svg'): os.getenv('CUSTOM_SVG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/favicon-96x96.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/apple-touch-icon.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/web-app-manifest-192x192.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/web-app-manifest-512x512.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/splash.png'): os.getenv('CUSTOM_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/favicon.ico'): os.getenv('CUSTOM_ICO', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/favicon-dark.png'): os.getenv('CUSTOM_DARK_PNG', ''), + os.path.join(FRONTEND_BUILD_DIR, 'static/splash-dark.png'): os.getenv('CUSTOM_DARK_PNG', ''), }, - "metadata": { - "type": "enterprise", - "organization_name": os.getenv("ORGANIZATION_NAME", "OpenWebui"), + 'metadata': { + 'type': 'enterprise', + 'organization_name': os.getenv('ORGANIZATION_NAME', 'OpenWebui'), }, } try: for k, v in payload.items(): - if k == "resources": + if k == 'resources': for p, c in v.items(): if c: - globals().get("override_static", lambda a, b: None)(p, c) - elif k == "count": - setattr(app.state, "USER_COUNT", v) - elif k == "name": - setattr(app.state, "WEBUI_NAME", v) - elif k == "metadata": - setattr(app.state, "LICENSE_METADATA", v) + globals().get('override_static', lambda a, b: None)(p, c) + elif k == 'count': + setattr(app.state, 'USER_COUNT', v) + elif k == 'name': + setattr(app.state, 'WEBUI_NAME', v) + elif k == 'metadata': + setattr(app.state, 'LICENSE_METADATA', v) return True except Exception as ex: - log.exception(f"License: Uncaught Exception: {ex}") + log.exception(f'License: Uncaught Exception: {ex}') return True @@ -180,12 +148,12 @@ def get_license_data(app, key): def get_password_hash(password: str) -> str: """Hash a password using bcrypt""" - return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') def validate_password(password: str) -> bool: # The password passed to bcrypt must be 72 bytes or fewer. If it is longer, it will be truncated before hashing. - if len(password.encode("utf-8")) > 72: + if len(password.encode('utf-8')) > 72: raise Exception( ERROR_MESSAGES.PASSWORD_TOO_LONG, ) @@ -201,23 +169,26 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: """Verify a password against its hash""" return ( bcrypt.checkpw( - plain_password.encode("utf-8"), - hashed_password.encode("utf-8"), + plain_password.encode('utf-8'), + hashed_password.encode('utf-8'), ) if hashed_password else None ) +# Let the one who signed this token be remembered at every gate, +# and may the claims therein honor the creator long after +# the session has closed. def create_token(data: dict, expires_delta: Union[timedelta, None] = None) -> str: payload = data.copy() if expires_delta: expire = datetime.now(UTC) + expires_delta - payload.update({"exp": expire}) + payload.update({'exp': expire}) jti = str(uuid.uuid4()) - payload.update({"jti": jti}) + payload.update({'jti': jti}) encoded_jwt = jwt.encode(payload, SESSION_SECRET, algorithm=ALGORITHM) return encoded_jwt @@ -234,12 +205,10 @@ def decode_token(token: str) -> Optional[dict]: async def is_valid_token(request, decoded) -> bool: # Require Redis to check revoked tokens if request.app.state.redis: - jti = decoded.get("jti") + jti = decoded.get('jti') if jti: - revoked = await request.app.state.redis.get( - f"{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked" - ) + revoked = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked') if revoked: return False @@ -255,37 +224,35 @@ async def invalidate_token(request, token): # Require Redis to store revoked tokens if request.app.state.redis: - jti = decoded.get("jti") - exp = decoded.get("exp") + jti = decoded.get('jti') + exp = decoded.get('exp') if jti and exp: - ttl = exp - int( - datetime.now(UTC).timestamp() - ) # Calculate time-to-live for the token + ttl = exp - int(datetime.now(UTC).timestamp()) # Calculate time-to-live for the token if ttl > 0: # Store the revoked token in Redis with an expiration time await request.app.state.redis.set( - f"{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked", - "1", + f'{REDIS_KEY_PREFIX}:auth:token:{jti}:revoked', + '1', ex=ttl, ) def extract_token_from_auth_header(auth_header: str): - return auth_header[len("Bearer ") :] + return auth_header[len('Bearer ') :] def create_api_key(): - key = str(uuid.uuid4()).replace("-", "") - return f"sk-{key}" + key = str(uuid.uuid4()).replace('-', '') + return f'sk-{key}' def get_http_authorization_cred(auth_header: Optional[str]): if not auth_header: return None try: - scheme, credentials = auth_header.split(" ") + scheme, credentials = auth_header.split(' ') return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) except Exception: return None @@ -306,27 +273,27 @@ async def get_current_user( if auth_token is not None: token = auth_token.credentials - if token is None and "token" in request.cookies: - token = request.cookies.get("token") + if token is None and 'token' in request.cookies: + token = request.cookies.get('token') # Fallback to request.state.token (set by middleware, e.g. for x-api-key) - if token is None and hasattr(request.state, "token") and request.state.token: + if token is None and hasattr(request.state, 'token') and request.state.token: token = request.state.token.credentials if token is None: - raise HTTPException(status_code=401, detail="Not authenticated") + raise HTTPException(status_code=401, detail='Not authenticated') # auth by api key - if token.startswith("sk-"): + if token.startswith('sk-'): user = get_current_user_by_api_key(request, token) # Add user info to current span current_span = trace.get_current_span() if current_span: - current_span.set_attribute("client.user.id", user.id) - current_span.set_attribute("client.user.email", user.email) - current_span.set_attribute("client.user.role", user.role) - current_span.set_attribute("client.auth.type", "api_key") + current_span.set_attribute('client.user.id', user.id) + current_span.set_attribute('client.user.email', user.email) + current_span.set_attribute('client.user.role', user.role) + current_span.set_attribute('client.auth.type', 'api_key') return user @@ -337,17 +304,17 @@ async def get_current_user( except Exception as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", + detail='Invalid token', ) - if data is not None and "id" in data: - if data.get("jti") and not await is_valid_token(request, data): + if data is not None and 'id' in data: + if data.get('jti') and not await is_valid_token(request, data): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token", + detail='Invalid token', ) - user = Users.get_user_by_id(data["id"]) + user = Users.get_user_by_id(data['id']) if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -355,22 +322,20 @@ async def get_current_user( ) else: if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: - trusted_email = request.headers.get( - WEBUI_AUTH_TRUSTED_EMAIL_HEADER, "" - ).lower() + trusted_email = request.headers.get(WEBUI_AUTH_TRUSTED_EMAIL_HEADER, '').lower() if trusted_email and user.email != trusted_email: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="User mismatch. Please sign in again.", + detail='User mismatch. Please sign in again.', ) # Add user info to current span current_span = trace.get_current_span() if current_span: - current_span.set_attribute("client.user.id", user.id) - current_span.set_attribute("client.user.email", user.email) - current_span.set_attribute("client.user.role", user.role) - current_span.set_attribute("client.auth.type", "jwt") + current_span.set_attribute('client.user.id', user.id) + current_span.set_attribute('client.user.email', user.email) + current_span.set_attribute('client.user.role', user.role) + current_span.set_attribute('client.auth.type', 'jwt') # Refresh the user's last active timestamp asynchronously # to prevent blocking the request @@ -384,15 +349,15 @@ async def get_current_user( ) except Exception as e: # Delete the token cookie - if request.cookies.get("token"): - response.delete_cookie("token") + if request.cookies.get('token'): + response.delete_cookie('token') - if request.cookies.get("oauth_id_token"): - response.delete_cookie("oauth_id_token") + if request.cookies.get('oauth_id_token'): + response.delete_cookie('oauth_id_token') # Delete OAuth session if present - if request.cookies.get("oauth_session_id"): - response.delete_cookie("oauth_session_id") + if request.cookies.get('oauth_session_id'): + response.delete_cookie('oauth_session_id') raise e @@ -408,31 +373,29 @@ def get_current_user_by_api_key(request, api_key: str): ) if not request.state.enable_api_keys or ( - user.role != "admin" + user.role != 'admin' and not has_permission( user.id, - "features.api_keys", + 'features.api_keys', request.app.state.config.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) # Add user info to current span current_span = trace.get_current_span() if current_span: - current_span.set_attribute("client.user.id", user.id) - current_span.set_attribute("client.user.email", user.email) - current_span.set_attribute("client.user.role", user.role) - current_span.set_attribute("client.auth.type", "api_key") + current_span.set_attribute('client.user.id', user.id) + current_span.set_attribute('client.user.email', user.email) + current_span.set_attribute('client.user.role', user.role) + current_span.set_attribute('client.auth.type', 'api_key') Users.update_last_active_by_id(user.id) return user def get_verified_user(user=Depends(get_current_user)): - if user.role not in {"user", "admin"}: + if user.role not in {'user', 'admin'}: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -441,7 +404,7 @@ def get_verified_user(user=Depends(get_current_user)): def get_admin_user(user=Depends(get_current_user)): - if user.role != "admin": + if user.role != 'admin': raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED, @@ -449,7 +412,7 @@ def get_admin_user(user=Depends(get_current_user)): return user -def create_admin_user(email: str, password: str, name: str = "Admin"): +def create_admin_user(email: str, password: str, name: str = 'Admin'): """ Create an admin user from environment variables. Used for headless/automated deployments. @@ -460,26 +423,26 @@ def create_admin_user(email: str, password: str, name: str = "Admin"): return None if Users.has_users(): - log.debug("Users already exist, skipping admin creation") + log.debug('Users already exist, skipping admin creation') return None - log.info(f"Creating admin account from environment variables: {email}") + log.info(f'Creating admin account from environment variables: {email}') try: hashed = get_password_hash(password) user = Auths.insert_new_auth( email=email.lower(), password=hashed, name=name, - role="admin", + role='admin', ) if user: - log.info(f"Admin account created successfully: {email}") + log.info(f'Admin account created successfully: {email}') return user else: - log.error("Failed to create admin account from environment variables") + log.error('Failed to create admin account from environment variables') return None except Exception as e: - log.error(f"Error creating admin account: {e}") + log.error(f'Error creating admin account: {e}') return None @@ -647,34 +610,29 @@ def create_admin_user(email: str, password: str, name: str = "Admin"): def get_email_code_key(code: str) -> str: - return f"email_verify:{code}" + return f'email_verify:{code}' def send_verify_email(email: str): redis = get_redis_connection( redis_url=REDIS_URL, - redis_sentinels=get_sentinels_from_env( - REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT - ), + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), redis_cluster=REDIS_CLUSTER, ) - code = f"{uuid.uuid4().hex}{uuid.uuid1().hex}" + code = f'{uuid.uuid4().hex}{uuid.uuid1().hex}' redis.set(name=get_email_code_key(code=code), value=email, ex=timedelta(days=1)) - link = f"{WEBUI_URL.value.rstrip('/')}/api/v1/auths/signup_verify/{code}" + link = f'{WEBUI_URL.value.rstrip("/")}/api/v1/auths/signup_verify/{code}' send_email( receiver=email, - subject=f"{WEBUI_NAME} Email Verify", - body=verify_email_template - % {"title": f"{WEBUI_NAME} Email Verify", "link": link}, + subject=f'{WEBUI_NAME} Email Verify', + body=verify_email_template % {'title': f'{WEBUI_NAME} Email Verify', 'link': link}, ) def verify_email_by_code(code: str) -> str: redis = get_redis_connection( redis_url=REDIS_URL, - redis_sentinels=get_sentinels_from_env( - REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT - ), + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), redis_cluster=REDIS_CLUSTER, ) return redis.get(name=get_email_code_key(code=code)) diff --git a/backend/open_webui/utils/channels.py b/backend/open_webui/utils/channels.py index 312b5ea24c..6f85dfae1e 100644 --- a/backend/open_webui/utils/channels.py +++ b/backend/open_webui/utils/channels.py @@ -1,16 +1,16 @@ import re -def extract_mentions(message: str, triggerChar: str = "@"): +def extract_mentions(message: str, triggerChar: str = '@'): # Escape triggerChar in case it's a regex special character triggerChar = re.escape(triggerChar) - pattern = rf"<{triggerChar}([A-Z]):([^|>]+)" + pattern = rf'<{triggerChar}([A-Z]):([^|>]+)' matches = re.findall(pattern, message) - return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches] + return [{'id_type': id_type, 'id': id_value} for id_type, id_value in matches] -def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True): +def replace_mentions(message: str, triggerChar: str = '@', use_label: bool = True): """ Replace mentions in the message with either their label (after the pipe `|`) or their id if no label exists. @@ -27,5 +27,5 @@ def replacer(match): return label if use_label and label else id_value # Regex captures: idType, id, optional label - pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>" + pattern = rf'<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>' return re.sub(pattern, replacer, message) diff --git a/backend/open_webui/utils/chat.py b/backend/open_webui/utils/chat.py index 71efd7423d..9bbb863fae 100644 --- a/backend/open_webui/utils/chat.py +++ b/backend/open_webui/utils/chat.py @@ -63,26 +63,28 @@ log = logging.getLogger(__name__) +# When the question has been asked, let silence not be the +# answer. But if the answer must wait, let it come honest. async def generate_direct_chat_completion( request: Request, form_data: dict, user: Any, models: dict, ): - log.info("generate_direct_chat_completion") + log.info('generate_direct_chat_completion') - metadata = form_data.pop("metadata", {}) + metadata = form_data.pop('metadata', {}) - user_id = metadata.get("user_id") - session_id = metadata.get("session_id") + user_id = metadata.get('user_id') + session_id = metadata.get('session_id') request_id = str(uuid.uuid4()) # Generate a unique request ID event_caller = get_event_call(metadata) - channel = f"{user_id}:{session_id}:{request_id}" - logging.info(f"WebSocket channel: {channel}") + channel = f'{user_id}:{session_id}:{request_id}' + logging.info(f'WebSocket channel: {channel}') - if form_data.get("stream"): + if form_data.get('stream'): q = asyncio.Queue() async def message_listener(sid, data): @@ -97,19 +99,19 @@ async def message_listener(sid, data): # Start processing chat completion in background res = await event_caller( { - "type": "request:chat:completion", - "data": { - "form_data": form_data, - "model": models[form_data["model"]], - "channel": channel, - "session_id": session_id, + 'type': 'request:chat:completion', + 'data': { + 'form_data': form_data, + 'model': models[form_data['model']], + 'channel': channel, + 'session_id': session_id, }, } ) - log.info(f"res: {res}") + log.info(f'res: {res}') - if res.get("status", False): + if res.get('status', False): # Define a generator to stream responses async def event_generator(): nonlocal q @@ -117,47 +119,45 @@ async def event_generator(): while True: data = await q.get() # Wait for new messages if isinstance(data, dict): - if "done" in data and data["done"]: + if 'done' in data and data['done']: break # Stop streaming when 'done' is received - yield f"data: {json.dumps(data)}\n\n" + yield f'data: {json.dumps(data)}\n\n' elif isinstance(data, str): - if "data:" in data: - yield f"{data}\n\n" + if 'data:' in data: + yield f'{data}\n\n' else: - yield f"data: {data}\n\n" + yield f'data: {data}\n\n' except Exception as e: - log.debug(f"Error in event generator: {e}") + log.debug(f'Error in event generator: {e}') pass # Define a background task to run the event generator async def background(): try: - del sio.handlers["/"][channel] + del sio.handlers['/'][channel] except Exception as e: pass # Return the streaming response - return StreamingResponse( - event_generator(), media_type="text/event-stream", background=background - ) + return StreamingResponse(event_generator(), media_type='text/event-stream', background=background) else: raise Exception(str(res)) else: res = await event_caller( { - "type": "request:chat:completion", - "data": { - "form_data": form_data, - "model": models[form_data["model"]], - "channel": channel, - "session_id": session_id, + 'type': 'request:chat:completion', + 'data': { + 'form_data': form_data, + 'model': models[form_data['model']], + 'channel': channel, + 'session_id': session_id, }, } ) - if "error" in res and res["error"]: - raise Exception(res["error"]) + if 'error' in res and res['error']: + raise Exception(res['error']) return res @@ -171,72 +171,86 @@ async def generate_chat_completion( ): check_credit_by_user_id(user_id=user.id, form_data=form_data) - log.debug(f"generate_chat_completion: {form_data}") + log.debug(f'generate_chat_completion: {form_data}') if BYPASS_MODEL_ACCESS_CONTROL: bypass_filter = True - if hasattr(request.state, "metadata"): - if "metadata" not in form_data: - form_data["metadata"] = request.state.metadata + # Propagate bypass_filter via request.state so that downstream route + # handlers (openai/ollama) can read it without exposing it as a query param. + request.state.bypass_filter = bypass_filter + + if hasattr(request.state, 'metadata'): + if 'metadata' not in form_data: + form_data['metadata'] = request.state.metadata else: - form_data["metadata"] = { - **form_data["metadata"], + form_data['metadata'] = { + **form_data['metadata'], **request.state.metadata, } - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } - log.debug(f"direct connection to model: {models}") + log.debug(f'direct connection to model: {models}') else: models = request.app.state.MODELS - model_id = form_data["model"] + model_id = form_data['model'] if model_id not in models: - raise Exception("Model not found") + raise Exception('Model not found') model = models[model_id] - if getattr(request.state, "direct", False): - return await generate_direct_chat_completion( - request, form_data, user=user, models=models - ) + if getattr(request.state, 'direct', False): + return await generate_direct_chat_completion(request, form_data, user=user, models=models) else: # Check if user has access to the model - if not bypass_filter and user.role == "user": + if not bypass_filter and user.role == 'user': try: check_model_access(user, model) except Exception as e: raise e - if model.get("owned_by") == "arena": - model_ids = model.get("info", {}).get("meta", {}).get("model_ids") - filter_mode = model.get("info", {}).get("meta", {}).get("filter_mode") - if model_ids and filter_mode == "exclude": + # Arena model โ€” sub-model was already resolved by process_chat_payload. + # Inject selected_model_id into the response for the frontend. + metadata = form_data.get('metadata', {}) + selected_model_id = metadata.pop('selected_model_id', None) + # Also clear from request.state.metadata to prevent the merge at + # lines 177-179 from re-adding it on the recursive call. + if hasattr(request.state, 'metadata'): + request.state.metadata.pop('selected_model_id', None) + + # Fallback: if generate_chat_completion is called with an arena model + # from a path that did NOT go through process_chat_payload (e.g., + # background tasks for title/follow-up/tags generation), resolve now. + if not selected_model_id and model.get('owned_by') == 'arena': + model_ids = model.get('info', {}).get('meta', {}).get('model_ids') + filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode') + if model_ids and filter_mode == 'exclude': model_ids = [ - model["id"] - for model in list(request.app.state.MODELS.values()) - if model.get("owned_by") != "arena" and model["id"] not in model_ids + available_model['id'] + for available_model in list(request.app.state.MODELS.values()) + if available_model.get('owned_by') != 'arena' and available_model['id'] not in model_ids ] - selected_model_id = None if isinstance(model_ids, list) and model_ids: selected_model_id = random.choice(model_ids) else: model_ids = [ - model["id"] - for model in list(request.app.state.MODELS.values()) - if model.get("owned_by") != "arena" + available_model['id'] + for available_model in list(request.app.state.MODELS.values()) + if available_model.get('owned_by') != 'arena' ] selected_model_id = random.choice(model_ids) - form_data["model"] = selected_model_id + form_data['model'] = selected_model_id - if form_data.get("stream") == True: + if selected_model_id: + if form_data.get('stream') == True: async def stream_wrapper(stream): - yield f"data: {json.dumps({'selected_model_id': selected_model_id})}\n\n" + yield f'data: {json.dumps({"selected_model_id": selected_model_id})}\n\n' async for chunk in stream: yield chunk @@ -249,7 +263,7 @@ async def stream_wrapper(stream): ) return StreamingResponse( stream_wrapper(response.body_iterator), - media_type="text/event-stream", + media_type='text/event-stream', background=response.background, ) else: @@ -263,15 +277,13 @@ async def stream_wrapper(stream): bypass_system_prompt=bypass_system_prompt, ) ), - "selected_model_id": selected_model_id, + 'selected_model_id': selected_model_id, } - if model.get("pipe"): + if model.get('pipe'): # Below does not require bypass_filter because this is the only route the uses this function and it is already bypassing the filter - return await generate_function_chat_completion( - request, form_data, user=user, models=models - ) - if model.get("owned_by") == "ollama": + return await generate_function_chat_completion(request, form_data, user=user, models=models) + if model.get('owned_by') == 'ollama': # Using /ollama/api/chat endpoint payload = copy.deepcopy(form_data) form_data = convert_payload_openai_to_ollama(form_data) @@ -279,15 +291,12 @@ async def stream_wrapper(stream): request=request, form_data=form_data, user=user, - bypass_filter=bypass_filter, bypass_system_prompt=bypass_system_prompt, ) - if form_data.get("stream"): - response.headers["content-type"] = "text/event-stream" + if form_data.get('stream'): + response.headers['content-type'] = 'text/event-stream' return StreamingResponse( - convert_streaming_response_ollama_to_openai( - user, model_id, payload, response - ), + convert_streaming_response_ollama_to_openai(user, model_id, payload, response), headers=dict(response.headers), background=response.background, ) @@ -306,7 +315,6 @@ async def stream_wrapper(stream): request=request, form_data=form_data, user=user, - bypass_filter=bypass_filter, bypass_system_prompt=bypass_system_prompt, ) @@ -318,55 +326,53 @@ async def chat_completed(request: Request, form_data: dict, user: Any): if not request.app.state.MODELS: await get_all_models(request, user=user) - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS data = form_data - model_id = data["model"] + model_id = data['model'] if model_id not in models: - raise Exception("Model not found") + raise Exception('Model not found') model = models[model_id] try: data = await process_pipeline_outlet_filter(request, data, user, models) except Exception as e: - raise Exception(f"Error: {e}") + raise Exception(f'Error: {e}') metadata = { - "chat_id": data["chat_id"], - "message_id": data["id"], - "filter_ids": data.get("filter_ids", []), - "session_id": data["session_id"], - "user_id": user.id, + 'chat_id': data['chat_id'], + 'message_id': data['id'], + 'filter_ids': data.get('filter_ids', []), + 'session_id': data['session_id'], + 'user_id': user.id, } extra_params = { - "__event_emitter__": get_event_emitter(metadata), - "__event_call__": get_event_call(metadata), - "__user__": user.model_dump() if isinstance(user, UserModel) else {}, - "__metadata__": metadata, - "__request__": request, - "__model__": model, + '__event_emitter__': get_event_emitter(metadata), + '__event_call__': get_event_call(metadata), + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__request__': request, + '__model__': model, } try: - filter_ids = get_sorted_filter_ids( - request, model, metadata.get("filter_ids", []) - ) + filter_ids = get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) filter_functions = Functions.get_functions_by_ids(filter_ids) result, _ = await process_filter_functions( request=request, filter_functions=filter_functions, - filter_type="outlet", + filter_type='outlet', form_data=data, extra_params=extra_params, ) return result except Exception as e: - raise Exception(f"Error: {e}") + raise Exception(f'Error: {e}') diff --git a/backend/open_webui/utils/code_interpreter.py b/backend/open_webui/utils/code_interpreter.py index a5de56a6c1..3e30c419ae 100644 --- a/backend/open_webui/utils/code_interpreter.py +++ b/backend/open_webui/utils/code_interpreter.py @@ -16,9 +16,9 @@ class ResultModel(BaseModel): Execute Code Result Model """ - stdout: Optional[str] = "" - stderr: Optional[str] = "" - result: Optional[str] = "" + stdout: Optional[str] = '' + stderr: Optional[str] = '' + result: Optional[str] = '' class JupyterCodeExecuter: @@ -30,8 +30,8 @@ def __init__( self, base_url: str, code: str, - token: str = "", - password: str = "", + token: str = '', + password: str = '', timeout: int = 60, ): """ @@ -46,9 +46,9 @@ def __init__( self.token = token self.password = password self.timeout = timeout - self.kernel_id = "" - if self.base_url[-1] != "/": - self.base_url += "/" + self.kernel_id = '' + if self.base_url[-1] != '/': + self.base_url += '/' self.session = aiohttp.ClientSession(trust_env=True, base_url=self.base_url) self.params = {} self.result = ResultModel() @@ -59,12 +59,10 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): if self.kernel_id: try: - async with self.session.delete( - f"api/kernels/{self.kernel_id}", params=self.params - ) as response: + async with self.session.delete(f'api/kernels/{self.kernel_id}', params=self.params) as response: response.raise_for_status() except Exception as err: - logger.exception("close kernel failed, %s", err) + logger.exception('close kernel failed, %s', err) await self.session.close() async def run(self) -> ResultModel: @@ -73,23 +71,23 @@ async def run(self) -> ResultModel: await self.init_kernel() await self.execute_code() except Exception as err: - logger.exception("execute code failed, %s", err) - self.result.stderr = f"Error: {err}" + logger.exception('execute code failed, %s', err) + self.result.stderr = f'Error: {err}' return self.result async def sign_in(self) -> None: # password authentication if self.password and not self.token: - async with self.session.get("login") as response: + async with self.session.get('login') as response: response.raise_for_status() - xsrf_token = response.cookies["_xsrf"].value + xsrf_token = response.cookies['_xsrf'].value if not xsrf_token: - raise ValueError("_xsrf token not found") + raise ValueError('_xsrf token not found') self.session.cookie_jar.update_cookies(response.cookies) - self.session.headers.update({"X-XSRFToken": xsrf_token}) + self.session.headers.update({'X-XSRFToken': xsrf_token}) async with self.session.post( - "login", - data={"_xsrf": xsrf_token, "password": self.password}, + 'login', + data={'_xsrf': xsrf_token, 'password': self.password}, allow_redirects=False, ) as response: response.raise_for_status() @@ -97,27 +95,22 @@ async def sign_in(self) -> None: # token authentication if self.token: - self.params.update({"token": self.token}) + self.params.update({'token': self.token}) async def init_kernel(self) -> None: - async with self.session.post(url="api/kernels", params=self.params) as response: + async with self.session.post(url='api/kernels', params=self.params) as response: response.raise_for_status() kernel_data = await response.json() - self.kernel_id = kernel_data["id"] + self.kernel_id = kernel_data['id'] def init_ws(self) -> (str, dict): - ws_base = self.base_url.replace("http", "ws", 1) - ws_params = "?" + "&".join([f"{key}={val}" for key, val in self.params.items()]) - websocket_url = f"{ws_base}api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ''}" + ws_base = self.base_url.replace('http', 'ws', 1) + ws_params = '?' + '&'.join([f'{key}={val}' for key, val in self.params.items()]) + websocket_url = f'{ws_base}api/kernels/{self.kernel_id}/channels{ws_params if len(ws_params) > 1 else ""}' ws_headers = {} if self.password and not self.token: ws_headers = { - "Cookie": "; ".join( - [ - f"{cookie.key}={cookie.value}" - for cookie in self.session.cookie_jar - ] - ), + 'Cookie': '; '.join([f'{cookie.key}={cookie.value}' for cookie in self.session.cookie_jar]), **self.session.headers, } return websocket_url, ws_headers @@ -126,9 +119,7 @@ async def execute_code(self) -> None: # initialize ws websocket_url, ws_headers = self.init_ws() # execute - async with websockets.connect( - websocket_url, additional_headers=ws_headers - ) as ws: + async with websockets.connect(websocket_url, additional_headers=ws_headers) as ws: await self.execute_in_jupyter(ws) async def execute_in_jupyter(self, ws) -> None: @@ -137,71 +128,69 @@ async def execute_in_jupyter(self, ws) -> None: await ws.send( json.dumps( { - "header": { - "msg_id": msg_id, - "msg_type": "execute_request", - "username": "user", - "session": uuid.uuid4().hex, - "date": "", - "version": "5.3", + 'header': { + 'msg_id': msg_id, + 'msg_type': 'execute_request', + 'username': 'user', + 'session': uuid.uuid4().hex, + 'date': '', + 'version': '5.3', }, - "parent_header": {}, - "metadata": {}, - "content": { - "code": self.code, - "silent": False, - "store_history": True, - "user_expressions": {}, - "allow_stdin": False, - "stop_on_error": True, + 'parent_header': {}, + 'metadata': {}, + 'content': { + 'code': self.code, + 'silent': False, + 'store_history': True, + 'user_expressions': {}, + 'allow_stdin': False, + 'stop_on_error': True, }, - "channel": "shell", + 'channel': 'shell', } ) ) # parse message - stdout, stderr, result = "", "", [] + stdout, stderr, result = '', '', [] while True: try: # wait for message message = await asyncio.wait_for(ws.recv(), self.timeout) message_data = json.loads(message) # msg id not match, skip - if message_data.get("parent_header", {}).get("msg_id") != msg_id: + if message_data.get('parent_header', {}).get('msg_id') != msg_id: continue # check message type - msg_type = message_data.get("msg_type") + msg_type = message_data.get('msg_type') match msg_type: - case "stream": - if message_data["content"]["name"] == "stdout": - stdout += message_data["content"]["text"] - elif message_data["content"]["name"] == "stderr": - stderr += message_data["content"]["text"] - case "execute_result" | "display_data": - data = message_data["content"]["data"] - if "image/png" in data: - result.append(f"data:image/png;base64,{data['image/png']}") - elif "text/plain" in data: - result.append(data["text/plain"]) - case "error": - stderr += "\n".join(message_data["content"]["traceback"]) - case "status": - if message_data["content"]["execution_state"] == "idle": + case 'stream': + if message_data['content']['name'] == 'stdout': + stdout += message_data['content']['text'] + elif message_data['content']['name'] == 'stderr': + stderr += message_data['content']['text'] + case 'execute_result' | 'display_data': + data = message_data['content']['data'] + if 'image/png' in data: + result.append(f'data:image/png;base64,{data["image/png"]}') + elif 'text/plain' in data: + result.append(data['text/plain']) + case 'error': + stderr += '\n'.join(message_data['content']['traceback']) + case 'status': + if message_data['content']['execution_state'] == 'idle': break except asyncio.TimeoutError: - stderr += "\nExecution timed out." + stderr += '\nExecution timed out.' break self.result.stdout = stdout.strip() self.result.stderr = stderr.strip() - self.result.result = "\n".join(result).strip() if result else "" + self.result.result = '\n'.join(result).strip() if result else '' async def execute_code_jupyter( - base_url: str, code: str, token: str = "", password: str = "", timeout: int = 60 + base_url: str, code: str, token: str = '', password: str = '', timeout: int = 60 ) -> dict: - async with JupyterCodeExecuter( - base_url, code, token, password, timeout - ) as executor: + async with JupyterCodeExecuter(base_url, code, token, password, timeout) as executor: result = await executor.run() return result.model_dump() diff --git a/backend/open_webui/utils/credit/alipay.py b/backend/open_webui/utils/credit/alipay.py index 94782115e4..2344209d57 100644 --- a/backend/open_webui/utils/credit/alipay.py +++ b/backend/open_webui/utils/credit/alipay.py @@ -43,16 +43,12 @@ def __init__(self): self._client = DefaultAlipayClient(self._client_config, logger) def verify(self, payload: dict) -> bool: - sign = payload.get("sign", "") + sign = payload.get('sign', '') if not sign: return False - payload_filtered = [ - f"{k}={unquote(v)}" - for k, v in payload.items() - if k not in ["sign", "sign_type"] - ] + payload_filtered = [f'{k}={unquote(v)}' for k, v in payload.items() if k not in ['sign', 'sign_type']] payload_filtered.sort() - payload_content = "&".join(payload_filtered) + payload_content = '&'.join(payload_filtered) try: return verify_with_rsa( self._client_config.alipay_public_key, @@ -60,27 +56,25 @@ def verify(self, payload: dict) -> bool: sign, ) except Exception as err: - logger.error("alipay verify error: %s", err) + logger.error('alipay verify error: %s', err) return False async def create_trade(self, out_trade_no: str, amount: float) -> dict: # check for amount if not check_amount(amount, ALIPAY_AMOUNT_CONTROL.value): return { - "code": -1, - "msg": f"amount invalid, allows {' '.join(ALIPAY_AMOUNT_CONTROL.value.split(','))}", + 'code': -1, + 'msg': f'amount invalid, allows {" ".join(ALIPAY_AMOUNT_CONTROL.value.split(","))}', } # build request request_model = AlipayTradePrecreateModel() request_model.out_trade_no = out_trade_no - request_model.total_amount = f"{amount:.2f}" - request_model.subject = f"{WEBUI_NAME} Credit" + request_model.total_amount = f'{amount:.2f}' + request_model.subject = f'{WEBUI_NAME} Credit' if ALIPAY_PRODUCT_CODE.value: request_model.product_code = ALIPAY_PRODUCT_CODE.value request = AlipayTradePrecreateRequest(biz_model=request_model) - request.notify_url = ( - f"{ALIPAY_CALLBACK_HOST.value.rstrip('/')}/api/v1/credit/callback/alipay" - ) + request.notify_url = f'{ALIPAY_CALLBACK_HOST.value.rstrip("/")}/api/v1/credit/callback/alipay' # do request try: response_content = self._client.execute(request) @@ -88,11 +82,11 @@ async def create_trade(self, out_trade_no: str, amount: float) -> dict: response.parse_response_content(response_content) if response.is_success(): return { - "code": 1, - "trade_no": response.out_trade_no, - "qrcode": response.qr_code, + 'code': 1, + 'trade_no': response.out_trade_no, + 'qrcode': response.qr_code, } - return {"code": -1, "msg": str(response_content)} + return {'code': -1, 'msg': str(response_content)} except Exception as err: - logger.exception("alipay create trade error: %s", err) - return {"code": -1, "msg": str(err)} + logger.exception('alipay create trade error: %s', err) + return {'code': -1, 'msg': str(err)} diff --git a/backend/open_webui/utils/credit/ezfp.py b/backend/open_webui/utils/credit/ezfp.py index e8216323c0..a0bd62bf26 100644 --- a/backend/open_webui/utils/credit/ezfp.py +++ b/backend/open_webui/utils/credit/ezfp.py @@ -21,68 +21,57 @@ class EZFPClient: def get_device_from_ua(self, ua: str) -> str: ua = ua.lower() - if ua.find("micromessenger") != -1: - return "wechat" - if ua.find("qq") != -1: - return "qq" - if ua.find("alipay") != -1: - return "alipay" - if ua.find("android") != -1 or ua.find("iphone") != -1: - return "mobile" - return "pc" + if ua.find('micromessenger') != -1: + return 'wechat' + if ua.find('qq') != -1: + return 'qq' + if ua.find('alipay') != -1: + return 'alipay' + if ua.find('android') != -1 or ua.find('iphone') != -1: + return 'mobile' + return 'pc' def sign(self, payload: dict) -> dict: - params = [ - f"{k}={v}" - for k, v in payload.items() - if v and k not in ["sign", "sign_type"] - ] + params = [f'{k}={v}' for k, v in payload.items() if v and k not in ['sign', 'sign_type']] params.sort() - plain_text = "&".join(params) + EZFP_KEY.value + plain_text = '&'.join(params) + EZFP_KEY.value sign = hashlib.md5(plain_text.encode()).hexdigest() - payload["sign"] = sign - payload["sign_type"] = "MD5" + payload['sign'] = sign + payload['sign_type'] = 'MD5' return payload def verify(self, payload: dict) -> bool: - if payload["pid"] != EZFP_PID.value: + if payload['pid'] != EZFP_PID.value: return False payload2 = self.sign(copy.deepcopy(payload)) - return ( - payload["sign"] == payload2["sign"] - and payload["sign_type"] == payload2["sign_type"] - ) + return payload['sign'] == payload2['sign'] and payload['sign_type'] == payload2['sign_type'] - async def create_trade( - self, pay_type: str, out_trade_no: str, amount: float, client_ip: str, ua: str - ) -> dict: + async def create_trade(self, pay_type: str, out_trade_no: str, amount: float, client_ip: str, ua: str) -> dict: # check for amount if not check_amount(amount, EZFP_AMOUNT_CONTROL.value): return { - "code": -1, - "msg": f"amount invalid, allows {' '.join(EZFP_AMOUNT_CONTROL.value.split(','))}", + 'code': -1, + 'msg': f'amount invalid, allows {" ".join(EZFP_AMOUNT_CONTROL.value.split(","))}', } # submit payload = { - "pid": int(EZFP_PID.value), - "type": pay_type, - "out_trade_no": out_trade_no, - "notify_url": f"{EZFP_CALLBACK_HOST.value.rstrip('/')}/api/v1/credit/callback", - "return_url": f"{EZFP_CALLBACK_HOST.value.rstrip('/')}/api/v1/credit/callback/redirect", - "name": f"{WEBUI_NAME} Credit", - "money": "%.2f" % amount, - "clientip": client_ip, - "device": self.get_device_from_ua(ua=ua), + 'pid': int(EZFP_PID.value), + 'type': pay_type, + 'out_trade_no': out_trade_no, + 'notify_url': f'{EZFP_CALLBACK_HOST.value.rstrip("/")}/api/v1/credit/callback', + 'return_url': f'{EZFP_CALLBACK_HOST.value.rstrip("/")}/api/v1/credit/callback/redirect', + 'name': f'{WEBUI_NAME} Credit', + 'money': '%.2f' % amount, + 'clientip': client_ip, + 'device': self.get_device_from_ua(ua=ua), } payload = self.sign(payload) client = httpx.AsyncClient() try: - resp = await client.post( - url=f"{EZFP_ENDPOINT.value.rstrip('/')}/mapi.php", data=payload - ) + resp = await client.post(url=f'{EZFP_ENDPOINT.value.rstrip("/")}/mapi.php', data=payload) return resp.json() except Exception as err: - return {"code": -1, "msg": str(err)} + return {'code': -1, 'msg': str(err)} finally: await client.aclose() diff --git a/backend/open_webui/utils/credit/models.py b/backend/open_webui/utils/credit/models.py index db2c63a881..0f73b64d75 100644 --- a/backend/open_webui/utils/credit/models.py +++ b/backend/open_webui/utils/credit/models.py @@ -3,17 +3,17 @@ class CompletionTokensDetails(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') reasoning_tokens: Optional[int] = None class PromptTokensDetails(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') cached_tokens: Optional[int] = None class CompletionUsage(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') total_tokens: int prompt_tokens: int completion_tokens: int @@ -22,102 +22,92 @@ class CompletionUsage(BaseModel): input_tokens_details: Optional[PromptTokensDetails] = None output_tokens_details: Optional[CompletionTokensDetails] = None - @model_validator(mode="before") + @model_validator(mode='before') @classmethod def format_input(cls, data: dict) -> dict: if not isinstance(data, dict): return data # standard tokens - prompt_tokens = ( - data.pop("prompt_tokens", 0) - or data.pop("promptTokenCount", 0) - or data.pop("input_tokens", 0) - ) + prompt_tokens = data.pop('prompt_tokens', 0) or data.pop('promptTokenCount', 0) or data.pop('input_tokens', 0) completion_tokens = ( - data.pop("completion_tokens", 0) - or data.pop("candidatesTokenCount", 0) - or data.pop("output_tokens", 0) + data.pop('completion_tokens', 0) or data.pop('candidatesTokenCount', 0) or data.pop('output_tokens', 0) ) total_tokens = ( - data.pop("total_tokens", 0) - or data.pop("totalTokenCount", 0) - or (prompt_tokens + completion_tokens) + data.pop('total_tokens', 0) or data.pop('totalTokenCount', 0) or (prompt_tokens + completion_tokens) ) # update data data.update( { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": total_tokens, + 'prompt_tokens': prompt_tokens, + 'completion_tokens': completion_tokens, + 'total_tokens': total_tokens, } ) return data class ChatCompletionMessage(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') content: Optional[str] = None class ChoiceDelta(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') content: Optional[str] = None class Choice(BaseModel): - model_config = ConfigDict(extra="allow") - message: Optional[ChatCompletionMessage] = Field( - default_factory=lambda: ChatCompletionMessage() - ) + model_config = ConfigDict(extra='allow') + message: Optional[ChatCompletionMessage] = Field(default_factory=lambda: ChatCompletionMessage()) delta: Optional[ChoiceDelta] = Field(default_factory=lambda: ChoiceDelta()) class ChatCompletion(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') choices: List[Choice] = Field(default_factory=lambda: []) usage: Optional[CompletionUsage] = None class ChatCompletionChunk(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') choices: List[Choice] = Field(default_factory=lambda: []) usage: Optional[CompletionUsage] = None class FileFile(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') - file_data: Optional[str] = Field(default="") - file_id: Optional[str] = Field(default="") - filename: Optional[str] = Field(default="") + file_data: Optional[str] = Field(default='') + file_id: Optional[str] = Field(default='') + filename: Optional[str] = Field(default='') class InputAudio(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') - data: Optional[str] = Field(default="") - format: Optional[str] = Field(default="") + data: Optional[str] = Field(default='') + format: Optional[str] = Field(default='') class ImageURL(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') - url: Optional[str] = Field(default="") - detail: Optional[str] = Field(default="") + url: Optional[str] = Field(default='') + detail: Optional[str] = Field(default='') class MessageContent(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') - type: Optional[str] = Field(default="") - text: Optional[str] = Field(default="") + type: Optional[str] = Field(default='') + text: Optional[str] = Field(default='') image_url: Optional[ImageURL] = Field(default_factory=lambda: ImageURL()) input_audio: Optional[InputAudio] = Field(default_factory=lambda: InputAudio()) file: Optional[FileFile] = Field(default_factory=lambda: FileFile()) class MessageItem(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra='allow') role: str - content: Union[str, list[MessageContent]] = Field(default="") + content: Union[str, list[MessageContent]] = Field(default='') diff --git a/backend/open_webui/utils/credit/usage.py b/backend/open_webui/utils/credit/usage.py index 00827e6bca..7f1bbc873a 100644 --- a/backend/open_webui/utils/credit/usage.py +++ b/backend/open_webui/utils/credit/usage.py @@ -48,8 +48,8 @@ def __init__(self) -> None: def get_encoder( self, model_id: str, - model_prefix_to_remove: str = "", - default_model_for_encoding: str = "gpt-4o", + model_prefix_to_remove: str = '', + default_model_for_encoding: str = 'gpt-4o', ) -> Encoding: # remove prefix model_id_ops = model_id @@ -71,8 +71,8 @@ def calculate_usage( model_id: str, messages: List[dict], response: Union[ChatCompletion, ChatCompletionChunk], - model_prefix_to_remove: str = "", - default_model_for_encoding: str = "gpt-4o", + model_prefix_to_remove: str = '', + default_model_for_encoding: str = 'gpt-4o', ) -> Tuple[bool, CompletionUsage]: try: # use provider usage @@ -80,9 +80,7 @@ def calculate_usage( return True, response.usage # init - usage = CompletionUsage( - prompt_tokens=0, completion_tokens=0, total_tokens=0 - ) + usage = CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0) encoder = self.get_encoder( model_id=model_id, model_prefix_to_remove=model_prefix_to_remove, @@ -94,47 +92,37 @@ def calculate_usage( if cached_usage.prompt_tokens: usage.prompt_tokens = cached_usage.prompt_tokens else: - for message in [ - MessageItem.model_validate(message) for message in messages - ]: + for message in [MessageItem.model_validate(message) for message in messages]: if isinstance(message.content, str): - usage.prompt_tokens += len( - encoder.encode(message.content or "") - ) + usage.prompt_tokens += len(encoder.encode(message.content or '')) if isinstance(message.content, list): for item in message.content: item: MessageContent - if item.type == "text": - usage.prompt_tokens += len( - encoder.encode(item.text or "") - ) - elif item.type == "image_url": - usage.prompt_tokens += calculate_image_token( - model_id, item.image_url - ) + if item.type == 'text': + usage.prompt_tokens += len(encoder.encode(item.text or '')) + elif item.type == 'image_url': + usage.prompt_tokens += calculate_image_token(model_id, item.image_url) # completion tokens choices = response.choices if choices: choice = choices[0] if isinstance(response, ChatCompletion): - content = choice.message.content or "" + content = choice.message.content or '' usage.completion_tokens = len( # strip to avoid empty token calculation - encoder.encode(content.lstrip("")) + encoder.encode(content.lstrip('')) ) elif isinstance(response, ChatCompletionChunk): - content = choice.delta.content or "" + content = choice.delta.content or '' # strip to avoid empty token calculation - usage.completion_tokens = len( - encoder.encode(content.lstrip("")) - ) + usage.completion_tokens = len(encoder.encode(content.lstrip(''))) # total tokens usage.total_tokens = usage.prompt_tokens + usage.completion_tokens return False, usage except Exception as err: - logger.exception("[calculate_usage] failed: %s", err) + logger.exception('[calculate_usage] failed: %s', err) raise err @@ -161,15 +149,13 @@ def __init__( ) -> None: self.is_error = False self.empty_no_cost = not is_embedding and CREDIT_NO_CHARGE_EMPTY_RESPONSE.value - self.remote_id = "" + self.remote_id = '' self.user = user self.model_id = model_id self.model = Models.get_model_by_id(self.model_id) self.body = body self.is_stream = is_stream - self.usage = CompletionUsage( - prompt_tokens=0, completion_tokens=0, total_tokens=0 - ) + self.usage = CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0) ( self._prompt_unit_price, self._completion_unit_price, @@ -182,13 +168,7 @@ def __init__( self.request_unit_price, _, ) = get_model_price(model=self.model, is_embedding=is_embedding) - self.features = { - k - for k, v in ( - body.get("metadata", {}).get("features_for_credit", {}) or {} - ).items() - if v - } + self.features = {k for k, v in (body.get('metadata', {}).get('features_for_credit', {}) or {}).items() if v} self.custom_fees = self.build_custom_fees(body) self.is_official_usage = False @@ -204,37 +184,34 @@ def __exit__(self, exc_type, exc_val, exc_tb): amount=Decimal(-self.total_price), detail=SetCreditFormDetail( usage={ - "total_price": float(self.total_price), - "prompt_unit_price": float(self.prompt_unit_price), - "prompt_cache_unit_price": float(self.prompt_cache_unit_price), - "completion_unit_price": float(self.completion_unit_price), - "request_unit_price": float(self.request_unit_price), - "feature_price": float(self.feature_price), - "features": list(self.features), - "custom_fee": float(self.custom_price), - "custom_fee_detail": { - k: float(v / 1000 / 1000) - for k, v in self.custom_fees.items() - }, - "is_calculate": not self.is_official_usage, - "is_empty_response": self.is_empty_response, - "empty_no_cost": self.empty_no_cost, + 'total_price': float(self.total_price), + 'prompt_unit_price': float(self.prompt_unit_price), + 'prompt_cache_unit_price': float(self.prompt_cache_unit_price), + 'completion_unit_price': float(self.completion_unit_price), + 'request_unit_price': float(self.request_unit_price), + 'feature_price': float(self.feature_price), + 'features': list(self.features), + 'custom_fee': float(self.custom_price), + 'custom_fee_detail': {k: float(v / 1000 / 1000) for k, v in self.custom_fees.items()}, + 'is_calculate': not self.is_official_usage, + 'is_empty_response': self.is_empty_response, + 'empty_no_cost': self.empty_no_cost, **self.usage.model_dump(exclude_unset=True, exclude_none=True), }, api_params={ - "model": ( + 'model': ( self.model.model_dump(exclude_unset=True, exclude_none=True) if self.model - else {"id": self.model_id} + else {'id': self.model_id} ), - "is_stream": self.is_stream, + 'is_stream': self.is_stream, }, - desc=f"updated by {self.__class__.__name__}", + desc=f'updated by {self.__class__.__name__}', ), ) ) logger.info( - "[credit_deduct] user: %s; model: %s; tokens: %d %d; cost: %s", + '[credit_deduct] user: %s; model: %s; tokens: %d %d; cost: %s', self.user.name, self.model_id, self.usage.prompt_tokens, @@ -248,19 +225,13 @@ def is_empty_response(self) -> bool: @property def prompt_unit_price(self) -> Decimal: - if ( - self.usage.prompt_tokens >= self._prompt_long_ctx_tokens > 0 - and self._prompt_long_ctx_unit_price > 0 - ): + if self.usage.prompt_tokens >= self._prompt_long_ctx_tokens > 0 and self._prompt_long_ctx_unit_price > 0: return self._prompt_long_ctx_unit_price return self._prompt_unit_price @property def prompt_cache_unit_price(self) -> Decimal: - if ( - self.usage.prompt_tokens >= self._prompt_long_ctx_tokens > 0 - and self._prompt_long_ctx_cache_unit_price > 0 - ): + if self.usage.prompt_tokens >= self._prompt_long_ctx_tokens > 0 and self._prompt_long_ctx_cache_unit_price > 0: return self._prompt_long_ctx_cache_unit_price return self._prompt_cache_unit_price @@ -279,15 +250,9 @@ def prompt_price(self) -> Decimal: return Decimal(0) cache_tokens = 0 # load from prompt_tokens_details or input_tokens_details - if ( - self.usage.prompt_tokens_details is not None - and self.usage.prompt_tokens_details.cached_tokens - ): + if self.usage.prompt_tokens_details is not None and self.usage.prompt_tokens_details.cached_tokens: cache_tokens = self.usage.prompt_tokens_details.cached_tokens or 0 - elif ( - self.usage.input_tokens_details is not None - and self.usage.input_tokens_details.cached_tokens - ): + elif self.usage.input_tokens_details is not None and self.usage.input_tokens_details.cached_tokens: cache_tokens = self.usage.input_tokens_details.cached_tokens or 0 # check cache price if cache_tokens > 0 and self.prompt_cache_unit_price > 0: @@ -332,54 +297,45 @@ def total_price(self) -> Decimal: if self.request_unit_price > 0: total_price = self.request_price + self.feature_price + self.custom_price else: - total_price = ( - self.prompt_price - + self.completion_price - + self.feature_price - + self.custom_price - ) + total_price = self.prompt_price + self.completion_price + self.feature_price + self.custom_price return max(total_price, Decimal(USAGE_CALCULATE_MINIMUM_COST.value)) def add_usage_to_resp(self, response: dict) -> dict: if not isinstance(response, dict): return response - response["usage"] = self.usage_with_cost + response['usage'] = self.usage_with_cost return response @property def usage_with_cost(self) -> dict: return { - "total_cost": float(self.total_price), - "cost_detail": { - "prompt_price": float(self.prompt_price), - "completion_price": float(self.completion_price), - "request_price": float(self.request_price), - "feature_price": float(self.feature_price), - "features": list(self.features), - "custom_fee": float(self.custom_price), - "custom_fee_detail": { - k: float(v / 1000 / 1000) for k, v in self.custom_fees.items() - }, - "is_calculate": not self.is_official_usage, - "is_error": self.is_error, - "is_empty_response": self.is_empty_response, - "empty_no_cost": self.empty_no_cost, + 'total_cost': float(self.total_price), + 'cost_detail': { + 'prompt_price': float(self.prompt_price), + 'completion_price': float(self.completion_price), + 'request_price': float(self.request_price), + 'feature_price': float(self.feature_price), + 'features': list(self.features), + 'custom_fee': float(self.custom_price), + 'custom_fee_detail': {k: float(v / 1000 / 1000) for k, v in self.custom_fees.items()}, + 'is_calculate': not self.is_official_usage, + 'is_error': self.is_error, + 'is_empty_response': self.is_empty_response, + 'empty_no_cost': self.empty_no_cost, }, **self.usage.model_dump(exclude_unset=True, exclude_none=True), } @property def usage_message(self) -> str: - return "data: %s\n\n" % json.dumps( + return 'data: %s\n\n' % json.dumps( { - "id": self.remote_id, - "created": int(time.time()), - "model": self.model_id, - "choices": [], - "object": ( - "chat.completion.chunk" if self.is_stream else "chat.completion" - ), - "usage": self.usage_with_cost, + 'id': self.remote_id, + 'created': int(time.time()), + 'model': self.model_id, + 'choices': [], + 'object': ('chat.completion.chunk' if self.is_stream else 'chat.completion'), + 'usage': self.usage_with_cost, } ) @@ -391,28 +347,26 @@ def build_custom_fees(self, body: dict) -> dict: return custom_fees # Check config not empty custom_config_str = USAGE_CUSTOM_PRICE_CONFIG.value - if not custom_config_str or custom_config_str == "[]": + if not custom_config_str or custom_config_str == '[]': return custom_fees # Parse the custom config string try: # load as json custom_configs = json.loads(custom_config_str) if not isinstance(custom_configs, list): - logger.warning("[credit_deduct] custom price config is not a list") + logger.warning('[credit_deduct] custom price config is not a list') return custom_fees # parse config from body for config in custom_configs: if not isinstance(config, dict): - logger.warning( - "[credit_deduct] custom price config has no dict value" - ) + logger.warning('[credit_deduct] custom price config has no dict value') continue # load config - path = config["path"] - name = config["name"] - value = config["value"] - exists_check = config["exists"] - cost = config["cost"] + path = config['path'] + name = config['name'] + value = config['value'] + exists_check = config['exists'] + cost = config['cost'] if not path or cost <= 0: continue # Apply jsonpath to the request body @@ -431,36 +385,36 @@ def build_custom_fees(self, body: dict) -> dict: break except Exception as e: logger.warning( - "[credit_deduct] Error parse custom price config %s: %s", + '[credit_deduct] Error parse custom price config %s: %s', path, e, ) except Exception as e: - logger.warning("[credit_deduct] Error parse custom price: %s", e) + logger.warning('[credit_deduct] Error parse custom price: %s', e) return custom_fees def run(self, response: Union[dict, bytes, str]) -> None: try: self._run(response) except Exception as e: - logger.warning("[credit_deduct_failed] unknown error %s", e) + logger.warning('[credit_deduct_failed] unknown error %s', e) def _run(self, response: Union[dict, bytes, str]) -> None: if not isinstance(response, (dict, bytes, str)): - logger.warning("[credit_deduct] response is type of %s", type(response)) + logger.warning('[credit_deduct] response is type of %s', type(response)) return # prompt messages - messages = self.body.get("messages", []) + messages = self.body.get('messages', []) if not messages: - raise HTTPException(status_code=400, detail="prompt messages is empty") + raise HTTPException(status_code=400, detail='prompt messages is empty') # stream if self.is_stream: _response = self.clean_response( response=response, default_response={ - "choices": [{"delta": {"content": self.to_str(response)}}], + 'choices': [{'delta': {'content': self.to_str(response)}}], }, ) if not _response: @@ -473,7 +427,7 @@ def _run(self, response: Union[dict, bytes, str]) -> None: _response = self.clean_response( response=response, default_response={ - "choices": [{"message": {"content": self.to_str(response)}}], + 'choices': [{'message': {'content': self.to_str(response)}}], }, ) if not _response: @@ -482,12 +436,12 @@ def _run(self, response: Union[dict, bytes, str]) -> None: response = ChatCompletion.model_validate(_response) # check for error - if _response.get("error"): + if _response.get('error'): self.is_error = True return # record is - self.remote_id = getattr(response, "id", "") + self.remote_id = getattr(response, 'id', '') # calculate is_official_usage, usage = calculator.calculate_usage( @@ -511,26 +465,22 @@ def _run(self, response: Union[dict, bytes, str]) -> None: if self.is_stream: self.usage.prompt_tokens = usage.prompt_tokens self.usage.completion_tokens += usage.completion_tokens - self.usage.total_tokens = ( - self.usage.prompt_tokens + self.usage.completion_tokens - ) + self.usage.total_tokens = self.usage.prompt_tokens + self.usage.completion_tokens return self.usage = usage - def clean_response( - self, response: Union[dict, bytes, str], default_response: dict - ) -> dict: + def clean_response(self, response: Union[dict, bytes, str], default_response: dict) -> dict: # dict if isinstance(response, dict): return response # str or bytes if isinstance(response, bytes): - _response = response.decode("utf-8") + _response = response.decode('utf-8') else: _response = response # remove prefix - _response = _response.strip().lstrip("data: ") - if _response.startswith("[DONE]") or not _response: + _response = _response.strip().lstrip('data: ') + if _response.startswith('[DONE]') or not _response: return {} try: _response = json.loads(_response) @@ -542,5 +492,5 @@ def to_str(self, data: any) -> str: if isinstance(data, str): return data.strip() if isinstance(data, bytes): - return data.decode("utf-8").strip() + return data.decode('utf-8').strip() return str(data) diff --git a/backend/open_webui/utils/credit/utils.py b/backend/open_webui/utils/credit/utils.py index 8658587700..a3e2def0e9 100644 --- a/backend/open_webui/utils/credit/utils.py +++ b/backend/open_webui/utils/credit/utils.py @@ -88,50 +88,16 @@ def get_model_price( # model price model_price = model.price or {} return ( - Decimal( - model_price.get("prompt_price", USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value) - ), - Decimal( - model_price.get( - "completion_price", USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value - ) - ), - Decimal( - model_price.get( - "prompt_long_ctx_tokens", USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value - ) - ), - Decimal( - model_price.get( - "prompt_long_ctx_price", USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value - ) - ), - Decimal( - model_price.get( - "completion_long_ctx_tokens", USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value - ) - ), - Decimal( - model_price.get( - "completion_long_ctx_price", USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value - ) - ), - Decimal( - model_price.get( - "prompt_cache_price", USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value - ) - ), - Decimal( - model_price.get( - "prompt_long_ctx_cache_price", USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value - ) - ), - Decimal( - model_price.get( - "request_price", USAGE_CALCULATE_DEFAULT_REQUEST_PRICE.value - ) - ), - Decimal(model_price.get("minimum_credit", 0)), + Decimal(model_price.get('prompt_price', USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value)), + Decimal(model_price.get('completion_price', USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value)), + Decimal(model_price.get('prompt_long_ctx_tokens', USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value)), + Decimal(model_price.get('prompt_long_ctx_price', USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value)), + Decimal(model_price.get('completion_long_ctx_tokens', USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value)), + Decimal(model_price.get('completion_long_ctx_price', USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value)), + Decimal(model_price.get('prompt_cache_price', USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value)), + Decimal(model_price.get('prompt_long_ctx_cache_price', USAGE_CALCULATE_DEFAULT_TOKEN_PRICE.value)), + Decimal(model_price.get('request_price', USAGE_CALCULATE_DEFAULT_REQUEST_PRICE.value)), + Decimal(model_price.get('minimum_credit', 0)), ) @@ -141,49 +107,29 @@ def get_feature_price(features: Union[set, list]) -> Decimal: price = Decimal(0) for feature in features: match feature: - case "image_generation": - price += ( - Decimal(USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE.value) / 1000 / 1000 - ) - case "code_interpreter": - price += ( - Decimal(USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE.value) - / 1000 - / 1000 - ) - case "web_search": - price += ( - Decimal(USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE.value) - / 1000 - / 1000 - ) - case "direct_tool_servers": - price += ( - Decimal(USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE.value) - / 1000 - / 1000 - ) + case 'image_generation': + price += Decimal(USAGE_CALCULATE_FEATURE_IMAGE_GEN_PRICE.value) / 1000 / 1000 + case 'code_interpreter': + price += Decimal(USAGE_CALCULATE_FEATURE_CODE_EXECUTE_PRICE.value) / 1000 / 1000 + case 'web_search': + price += Decimal(USAGE_CALCULATE_FEATURE_WEB_SEARCH_PRICE.value) / 1000 / 1000 + case 'direct_tool_servers': + price += Decimal(USAGE_CALCULATE_FEATURE_TOOL_SERVER_PRICE.value) / 1000 / 1000 return price def is_free_request(model_price: list, form_data: dict) -> bool: is_free_model = sum(float(price) for price in model_price) <= 0 - features = ( - form_data.get("features") - or (form_data.get("metadata") or {}).get("features") - or {} - ) + features = form_data.get('features') or (form_data.get('metadata') or {}).get('features') or {} is_feature_free = get_feature_price({k for k, v in features.items() if v}) <= 0 return is_free_model and is_feature_free -def check_credit_by_user_id( - user_id: str, form_data: dict, is_embedding: bool = False -) -> None: +def check_credit_by_user_id(user_id: str, form_data: dict, is_embedding: bool = False) -> None: # load model - model_id = form_data.get("model") or form_data.get("model_id") or "" + model_id = form_data.get('model') or form_data.get('model_id') or '' model = Models.get_model_by_id(model_id) ( prompt_price, @@ -212,18 +158,18 @@ def check_credit_by_user_id( ): return # load credit - metadata = form_data.get("metadata") or form_data + metadata = form_data.get('metadata') or form_data credit = Credits.init_credit_by_user_id(user_id=user_id) # check for credit if credit is None or credit.credit <= 0 or credit.credit < minimum_credit: if isinstance(metadata, dict) and metadata: - chat_id = metadata.get("chat_id") - message_id = metadata.get("message_id") or metadata.get("id") + chat_id = metadata.get('chat_id') + message_id = metadata.get('message_id') or metadata.get('id') if chat_id and message_id: Chats.upsert_message_to_chat_by_id_and_message_id( chat_id, message_id, - {"error": {"content": CREDIT_NO_CREDIT_MSG.value}}, + {'error': {'content': CREDIT_NO_CREDIT_MSG.value}}, ) raise HTTPException(status_code=403, detail=CREDIT_NO_CREDIT_MSG.value) @@ -239,35 +185,35 @@ def calculate_image_token(model_id: str, image: ImageURL) -> int: base_tokens = 85 - if image.detail == "low": + if image.detail == 'low': return 85 - if image.detail == "auto" or not image.detail: - image.detail = "high" + if image.detail == 'auto' or not image.detail: + image.detail = 'high' tile_tokens = 170 - if model_id.find("gpt-4o-mini") != -1: + if model_id.find('gpt-4o-mini') != -1: tile_tokens = 5667 base_tokens = 2833 - if model_id.find("gemini") != -1 or model_id.find("claude") != -1: + if model_id.find('gemini') != -1 or model_id.find('claude') != -1: return 3 * base_tokens - if image.url.startswith("http"): + if image.url.startswith('http'): with httpx.Client(trust_env=True, timeout=60) as client: response = client.get(image.url) response.raise_for_status() - image_data = base64.b64encode(response.content).decode("utf-8") + image_data = base64.b64encode(response.content).decode('utf-8') else: - if "," in image.url: - image_data = image.url.split(",", 1)[1] + if ',' in image.url: + image_data = image.url.split(',', 1)[1] else: from open_webui.utils.files import get_image_base64_from_url image_data = get_image_base64_from_url(image.url) or image.url - image_data = base64.b64decode(image_data.encode("utf-8")) + image_data = base64.b64decode(image_data.encode('utf-8')) image = Image.open(BytesIO(image_data)) width, height = image.size @@ -294,9 +240,9 @@ def calculate_image_token(model_id: str, image: ImageURL) -> int: def check_amount(amount: float, amount_control: str) -> bool: if not amount_control: return True - checks = amount_control.split(",") + checks = amount_control.split(',') for check in checks: - values = check.strip().split("-") + values = check.strip().split('-') if len(values) == 2: if float(values[0].strip()) <= amount <= float(values[1].strip()): return True diff --git a/backend/open_webui/utils/embeddings.py b/backend/open_webui/utils/embeddings.py index a2dc080cb5..251b5edf7e 100644 --- a/backend/open_webui/utils/embeddings.py +++ b/backend/open_webui/utils/embeddings.py @@ -43,35 +43,35 @@ async def generate_embeddings( bypass_filter = True # Attach extra metadata from request.state if present - if hasattr(request.state, "metadata"): - if "metadata" not in form_data: - form_data["metadata"] = request.state.metadata + if hasattr(request.state, 'metadata'): + if 'metadata' not in form_data: + form_data['metadata'] = request.state.metadata else: - form_data["metadata"] = { - **form_data["metadata"], + form_data['metadata'] = { + **form_data['metadata'], **request.state.metadata, } # If "direct" flag present, use only that model - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS - model_id = form_data.get("model") + model_id = form_data.get('model') if model_id not in models: - raise Exception("Model not found") + raise Exception('Model not found') model = models[model_id] # Access filtering - if not getattr(request.state, "direct", False): - if not bypass_filter and user.role == "user": + if not getattr(request.state, 'direct', False): + if not bypass_filter and user.role == 'user': check_model_access(user, model) # Ollama backend โ€” use /api/embed which supports batch input natively - if model.get("owned_by") == "ollama": + if model.get('owned_by') == 'ollama': ollama_payload = convert_embed_payload_openai_to_ollama(form_data) response = await ollama_embed( request=request, diff --git a/backend/open_webui/utils/files.py b/backend/open_webui/utils/files.py index af8818d59b..06bec33250 100644 --- a/backend/open_webui/utils/files.py +++ b/backend/open_webui/utils/files.py @@ -27,22 +27,22 @@ import requests -BASE64_IMAGE_URL_PREFIX = re.compile(r"data:image/\w+;base64,", re.IGNORECASE) -MARKDOWN_IMAGE_URL_PATTERN = re.compile(r"!\[(.*?)\]\((.+?)\)", re.IGNORECASE) +BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE) +MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE) def get_image_base64_from_url(url: str) -> Optional[str]: try: - if url.startswith("http"): + if url.startswith('http'): # Validate URL to prevent SSRF attacks against local/private networks validate_url(url) # Download the image from the URL response = requests.get(url) response.raise_for_status() image_data = response.content - encoded_string = base64.b64encode(image_data).decode("utf-8") - content_type = response.headers.get("Content-Type", "image/png") - return f"data:{content_type};base64,{encoded_string}" + encoded_string = base64.b64encode(image_data).decode('utf-8') + content_type = response.headers.get('Content-Type', 'image/png') + return f'data:{content_type};base64,{encoded_string}' else: file = Files.get_file_by_id(url) @@ -53,10 +53,10 @@ def get_image_base64_from_url(url: str) -> Optional[str]: file_path = Path(file_path) if file_path.is_file(): - with open(file_path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()).decode("utf-8") + 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) - return f"data:{content_type};base64,{encoded_string}" + return f'data:{content_type};base64,{encoded_string}' else: return None @@ -66,7 +66,7 @@ def get_image_base64_from_url(url: str) -> Optional[str]: def get_image_url_from_base64(request, base64_image_string, metadata, user): if BASE64_IMAGE_URL_PREFIX.match(base64_image_string): - image_url = "" + image_url = '' # Extract base64 image data from the line image_data, content_type = get_image_data(base64_image_string) if image_data is not None: @@ -89,7 +89,7 @@ def replace(match): if len(base64_string) > MIN_REPLACEMENT_URL_LENGTH: url = get_image_url_from_base64(request, base64_string, metadata, user) if url: - return f"![{match.group(1)}]({url})" + return f'![{match.group(1)}]({url})' return match.group(0) return MARKDOWN_IMAGE_URL_PATTERN.sub(replace, content) @@ -97,18 +97,16 @@ def replace(match): def load_b64_audio_data(b64_str): try: - if "," in b64_str: - header, b64_data = b64_str.split(",", 1) + if ',' in b64_str: + header, b64_data = b64_str.split(',', 1) else: b64_data = b64_str - header = "data:audio/wav;base64" + header = 'data:audio/wav;base64' audio_data = base64.b64decode(b64_data) - content_type = ( - header.split(";")[0].split(":")[1] if ";" in header else "audio/wav" - ) + content_type = header.split(';')[0].split(':')[1] if ';' in header else 'audio/wav' return audio_data, content_type except Exception as e: - print(f"Error decoding base64 audio data: {e}") + print(f'Error decoding base64 audio data: {e}') return None, None @@ -116,9 +114,9 @@ def upload_audio(request, audio_data, content_type, metadata, user): audio_format = mimetypes.guess_extension(content_type) file = UploadFile( file=io.BytesIO(audio_data), - filename=f"generated-{audio_format}", # will be converted to a unique ID on upload_file + filename=f'generated-{audio_format}', # will be converted to a unique ID on upload_file headers={ - "content-type": content_type, + 'content-type': content_type, }, ) file_item = upload_file_handler( @@ -128,13 +126,13 @@ def upload_audio(request, audio_data, content_type, metadata, user): process=False, user=user, ) - url = request.app.url_path_for("get_file_content_by_id", id=file_item.id) + url = request.app.url_path_for('get_file_content_by_id', id=file_item.id) return url def get_audio_url_from_base64(request, base64_audio_string, metadata, user): - if "data:audio/wav;base64" in base64_audio_string: - audio_url = "" + if 'data:audio/wav;base64' in base64_audio_string: + audio_url = '' # Extract base64 audio data from the line audio_data, content_type = load_b64_audio_data(base64_audio_string) if audio_data is not None: @@ -150,9 +148,9 @@ def get_audio_url_from_base64(request, base64_audio_string, metadata, user): def get_file_url_from_base64(request, base64_file_string, metadata, user): - if "data:image/png;base64" in base64_file_string: + if BASE64_IMAGE_URL_PREFIX.match(base64_file_string): return get_image_url_from_base64(request, base64_file_string, metadata, user) - elif "data:audio/wav;base64" in base64_file_string: + elif 'data:audio/wav;base64' in base64_file_string: return get_audio_url_from_base64(request, base64_file_string, metadata, user) return None @@ -170,10 +168,10 @@ def get_image_base64_from_file_id(id: str) -> Optional[str]: if file_path.is_file(): import base64 - with open(file_path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()).decode("utf-8") + 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) - return f"data:{content_type};base64,{encoded_string}" + return f'data:{content_type};base64,{encoded_string}' else: return None except Exception as e: diff --git a/backend/open_webui/utils/filter.py b/backend/open_webui/utils/filter.py index 9c71f0d651..df07dea4a1 100644 --- a/backend/open_webui/utils/filter.py +++ b/backend/open_webui/utils/filter.py @@ -14,9 +14,7 @@ def get_function_module(request, function_id, load_from_db=True): """ Get the function module by its ID. """ - function_module, _, _ = get_function_module_from_cache( - request, function_id, load_from_db - ) + function_module, _, _ = get_function_module_from_cache(request, function_id, load_from_db) return function_module @@ -24,34 +22,29 @@ def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None) def get_priority(function_id): try: function_module = get_function_module(request, function_id) - if function_module and hasattr(function_module, "Valves"): + if function_module and hasattr(function_module, 'Valves'): valves_db = Functions.get_function_valves_by_id(function_id) valves = function_module.Valves(**(valves_db if valves_db else {})) - return getattr(valves, "priority", 0) + return getattr(valves, 'priority', 0) except Exception: pass return 0 filter_ids = [function.id for function in Functions.get_global_filter_functions()] - if "info" in model and "meta" in model["info"]: - filter_ids.extend(model["info"]["meta"].get("filterIds", [])) + 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 Functions.get_functions_by_type("filter", active_only=True) - ] + active_filter_ids = {function.id for function in Functions.get_functions_by_type('filter', active_only=True)} def get_active_status(filter_id): function_module = get_function_module(request, filter_id) - if getattr(function_module, "toggle", None): - return filter_id in (enabled_filter_ids or []) + if getattr(function_module, 'toggle', None): + return filter_id in (enabled_filter_ids or set()) return True - active_filter_ids = [ - filter_id for filter_id in active_filter_ids if get_active_status(filter_id) - ] + active_filter_ids = {filter_id for filter_id in active_filter_ids if get_active_status(filter_id)} filter_ids = [fid for fid in filter_ids if fid in active_filter_ids] filter_ids.sort(key=lambda fid: (get_priority(fid), fid)) @@ -59,9 +52,9 @@ def get_active_status(filter_id): return filter_ids -async def process_filter_functions( - request, filter_functions, filter_type, form_data, extra_params -): +# 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): skip_files = None for function in filter_functions: @@ -70,53 +63,47 @@ async def process_filter_functions( if not filter: continue - function_module = get_function_module( - request, filter_id, load_from_db=(filter_type != "stream") - ) + function_module = get_function_module(request, filter_id, load_from_db=(filter_type != 'stream')) # Prepare handler function handler = getattr(function_module, filter_type, None) if not handler: continue # Check if the function has a file_handler variable - if filter_type == "inlet" and hasattr(function_module, "file_handler"): + if filter_type == 'inlet' and hasattr(function_module, 'file_handler'): skip_files = function_module.file_handler # Apply valves to the function - if hasattr(function_module, "valves") and hasattr(function_module, "Valves"): + if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'): valves = Functions.get_function_valves_by_id(filter_id) - function_module.valves = function_module.Valves( - **(valves if valves else {}) - ) + function_module.valves = function_module.Valves(**(valves if valves else {})) try: # Prepare parameters sig = inspect.signature(handler) - params = {"body": form_data} - if filter_type == "stream": - params = {"event": form_data} + params = {'body': form_data} + if filter_type == 'stream': + params = {'event': form_data} params = params | { k: v for k, v in { **extra_params, - "__id__": filter_id, + '__id__': filter_id, }.items() if k in sig.parameters } # Handle user parameters - if "__user__" in sig.parameters: - if hasattr(function_module, "UserValves"): + if '__user__' in sig.parameters: + if hasattr(function_module, 'UserValves'): try: - params["__user__"]["valves"] = function_module.UserValves( - **Functions.get_user_valves_by_id_and_user_id( - filter_id, params["__user__"]["id"] - ) + params['__user__']['valves'] = function_module.UserValves( + **Functions.get_user_valves_by_id_and_user_id(filter_id, params['__user__']['id']) ) except Exception as e: - log.exception(f"Failed to get user values: {e}") + log.exception(f'Failed to get user values: {e}') # Execute handler if inspect.iscoroutinefunction(handler): @@ -125,14 +112,14 @@ async def process_filter_functions( form_data = handler(**params) except Exception as e: - log.debug(f"Error in {filter_type} handler {filter_id}: {e}") + log.debug(f'Error in {filter_type} handler {filter_id}: {e}') raise e # Handle file cleanup for inlet if skip_files: - if "files" in form_data.get("metadata", {}): - del form_data["metadata"]["files"] - if "files" in form_data: - del form_data["files"] + if 'files' in form_data.get('metadata', {}): + del form_data['metadata']['files'] + if 'files' in form_data: + del form_data['files'] return form_data, {} diff --git a/backend/open_webui/utils/groups.py b/backend/open_webui/utils/groups.py index 26fc5d8434..90c4593cec 100644 --- a/backend/open_webui/utils/groups.py +++ b/backend/open_webui/utils/groups.py @@ -20,6 +20,4 @@ def apply_default_group_assignment( try: Groups.add_users_to_group(default_group_id, [user_id], db=db) except Exception as e: - log.error( - f"Failed to add user {user_id} to default group {default_group_id}: {e}" - ) + log.error(f'Failed to add user {user_id} to default group {default_group_id}: {e}') diff --git a/backend/open_webui/utils/headers.py b/backend/open_webui/utils/headers.py index f0b13c00d3..0baee5edb9 100644 --- a/backend/open_webui/utils/headers.py +++ b/backend/open_webui/utils/headers.py @@ -11,7 +11,7 @@ def include_user_info_headers(headers, user): return { **headers, - FORWARD_USER_INFO_HEADER_USER_NAME: quote(user.name, safe=" "), + FORWARD_USER_INFO_HEADER_USER_NAME: quote(user.name, safe=' '), FORWARD_USER_INFO_HEADER_USER_ID: user.id, FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email, FORWARD_USER_INFO_HEADER_USER_ROLE: user.role, diff --git a/backend/open_webui/utils/images/comfyui.py b/backend/open_webui/utils/images/comfyui.py index 3c402cbc17..497808c22d 100644 --- a/backend/open_webui/utils/images/comfyui.py +++ b/backend/open_webui/utils/images/comfyui.py @@ -13,99 +13,97 @@ log = logging.getLogger(__name__) -default_headers = {"User-Agent": "Mozilla/5.0"} +default_headers = {'User-Agent': 'Mozilla/5.0'} def queue_prompt(prompt, client_id, base_url, api_key): - log.info("queue_prompt") - p = {"prompt": prompt, "client_id": client_id} - data = json.dumps(p).encode("utf-8") - log.debug(f"queue_prompt data: {data}") + log.info('queue_prompt') + p = {'prompt': prompt, 'client_id': client_id} + data = json.dumps(p).encode('utf-8') + log.debug(f'queue_prompt data: {data}') try: req = urllib.request.Request( - f"{base_url}/prompt", + f'{base_url}/prompt', data=data, - headers={**default_headers, "Authorization": f"Bearer {api_key}"}, + headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, ) response = urllib.request.urlopen(req).read() return json.loads(response) except Exception as e: - log.exception(f"Error while queuing prompt: {e}") + log.exception(f'Error while queuing prompt: {e}') raise e def get_image(filename, subfolder, folder_type, base_url, api_key): - log.info("get_image") - data = {"filename": filename, "subfolder": subfolder, "type": folder_type} + log.info('get_image') + data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type} url_values = urllib.parse.urlencode(data) req = urllib.request.Request( - f"{base_url}/view?{url_values}", - headers={**default_headers, "Authorization": f"Bearer {api_key}"}, + f'{base_url}/view?{url_values}', + headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, ) with urllib.request.urlopen(req) as response: return response.read() def get_image_url(filename, subfolder, folder_type, base_url): - log.info("get_image") - data = {"filename": filename, "subfolder": subfolder, "type": folder_type} + log.info('get_image') + data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type} url_values = urllib.parse.urlencode(data) - return f"{base_url}/view?{url_values}" + return f'{base_url}/view?{url_values}' def get_history(prompt_id, base_url, api_key): - log.info("get_history") + log.info('get_history') req = urllib.request.Request( - f"{base_url}/history/{prompt_id}", - headers={**default_headers, "Authorization": f"Bearer {api_key}"}, + f'{base_url}/history/{prompt_id}', + headers={**default_headers, 'Authorization': f'Bearer {api_key}'}, ) with urllib.request.urlopen(req) as response: return json.loads(response.read()) def get_images(ws, workflow, client_id, base_url, api_key): - prompt_id = queue_prompt(workflow, client_id, base_url, api_key)["prompt_id"] + prompt_id = queue_prompt(workflow, client_id, base_url, api_key)['prompt_id'] output_images = [] while True: out = ws.recv() if isinstance(out, str): message = json.loads(out) - if message["type"] == "executing": - data = message["data"] - if data["node"] is None and data["prompt_id"] == prompt_id: + if message['type'] == 'executing': + data = message['data'] + if data['node'] is None and data['prompt_id'] == prompt_id: break # Execution is done else: continue # previews are binary data history = get_history(prompt_id, base_url, api_key)[prompt_id] - for node_id in history["outputs"]: - node_output = history["outputs"][node_id] - if node_id in workflow and workflow[node_id].get("class_type") in [ - "SaveImage", - "PreviewImage", + for node_id in history['outputs']: + node_output = history['outputs'][node_id] + if node_id in workflow and workflow[node_id].get('class_type') in [ + 'SaveImage', + 'PreviewImage', ]: - if "images" in node_output: - for image in node_output["images"]: - url = get_image_url( - image["filename"], image["subfolder"], image["type"], base_url - ) - output_images.append({"url": url}) - return {"data": output_images} + if 'images' in node_output: + for image in node_output['images']: + url = get_image_url(image['filename'], image['subfolder'], image['type'], base_url) + output_images.append({'url': url}) + return {'data': output_images} async def comfyui_upload_image(image_file_item, base_url, api_key): - url = f"{base_url}/api/upload/image" + url = f'{base_url}/api/upload/image' headers = {} if api_key: - headers["Authorization"] = f"Bearer {api_key}" + headers['Authorization'] = f'Bearer {api_key}' _, (filename, file_bytes, mime_type) = image_file_item form = aiohttp.FormData() - form.add_field("image", file_bytes, filename=filename, content_type=mime_type) - form.add_field("type", "input") # required by ComfyUI + form.add_field('image', file_bytes, filename=filename, content_type=mime_type) + form.add_field('type', 'input') # required by ComfyUI async with aiohttp.ClientSession() as session: async with session.post(url, data=form, headers=headers) as resp: @@ -116,7 +114,7 @@ async def comfyui_upload_image(image_file_item, base_url, api_key): class ComfyUINodeInput(BaseModel): type: Optional[str] = None node_ids: list[str] = [] - key: Optional[str] = "text" + key: Optional[str] = 'text' value: Optional[str] = None @@ -138,76 +136,56 @@ class ComfyUICreateImageForm(BaseModel): seed: Optional[int] = None -async def comfyui_create_image( - model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key -): - ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") +async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, client_id, base_url, api_key): + ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') workflow = json.loads(payload.workflow.workflow) for node in payload.workflow.nodes: if node.type: - if node.type == "model": + if node.type == 'model': for node_id in node.node_ids: - workflow[node_id]["inputs"][node.key] = model - elif node.type == "prompt": + workflow[node_id]['inputs'][node.key] = model + elif node.type == 'prompt': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "text" - ] = payload.prompt - elif node.type == "negative_prompt": + workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt + elif node.type == 'negative_prompt': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "text" - ] = payload.negative_prompt - elif node.type == "width": + workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt + elif node.type == 'width': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "width" - ] = payload.width - elif node.type == "height": + workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width + elif node.type == 'height': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "height" - ] = payload.height - elif node.type == "n": + workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height + elif node.type == 'n': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "batch_size" - ] = payload.n - elif node.type == "steps": + workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n + elif node.type == 'steps': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "steps" - ] = payload.steps - elif node.type == "seed": - seed = ( - payload.seed - if payload.seed - else random.randint(0, 1125899906842624) - ) + workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps + elif node.type == 'seed': + seed = payload.seed if payload.seed else random.randint(0, 1125899906842624) for node_id in node.node_ids: - workflow[node_id]["inputs"][node.key] = seed + workflow[node_id]['inputs'][node.key] = seed else: for node_id in node.node_ids: - workflow[node_id]["inputs"][node.key] = node.value + workflow[node_id]['inputs'][node.key] = node.value try: ws = websocket.WebSocket() - headers = {"Authorization": f"Bearer {api_key}"} - ws.connect(f"{ws_url}/ws?clientId={client_id}", header=headers) - log.info("WebSocket connection established.") + headers = {'Authorization': f'Bearer {api_key}'} + ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers) + log.info('WebSocket connection established.') except Exception as e: - log.exception(f"Failed to connect to WebSocket server: {e}") + log.exception(f'Failed to connect to WebSocket server: {e}') return None try: - log.info("Sending workflow to WebSocket server.") - log.info(f"Workflow: {workflow}") - images = await asyncio.to_thread( - get_images, ws, workflow, client_id, base_url, api_key - ) + log.info('Sending workflow to WebSocket server.') + log.info(f'Workflow: {workflow}') + images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key) except Exception as e: - log.exception(f"Error while receiving images: {e}") + log.exception(f'Error while receiving images: {e}') images = None ws.close() @@ -228,85 +206,65 @@ class ComfyUIEditImageForm(BaseModel): seed: Optional[int] = None -async def comfyui_edit_image( - model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key -): - ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://") +async def comfyui_edit_image(model: str, payload: ComfyUIEditImageForm, client_id, base_url, api_key): + ws_url = base_url.replace('http://', 'ws://').replace('https://', 'wss://') workflow = json.loads(payload.workflow.workflow) for node in payload.workflow.nodes: if node.type: - if node.type == "model": + if node.type == 'model': for node_id in node.node_ids: - workflow[node_id]["inputs"][node.key] = model - elif node.type == "image": + workflow[node_id]['inputs'][node.key] = model + elif node.type == 'image': if isinstance(payload.image, list): # check if multiple images are provided for idx, node_id in enumerate(node.node_ids): if idx < len(payload.image): - workflow[node_id]["inputs"][node.key] = payload.image[idx] + workflow[node_id]['inputs'][node.key] = payload.image[idx] else: for node_id in node.node_ids: - workflow[node_id]["inputs"][node.key] = payload.image - elif node.type == "prompt": + workflow[node_id]['inputs'][node.key] = payload.image + elif node.type == 'prompt': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "text" - ] = payload.prompt - elif node.type == "negative_prompt": + workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.prompt + elif node.type == 'negative_prompt': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "text" - ] = payload.negative_prompt - elif node.type == "width": + workflow[node_id]['inputs'][node.key if node.key else 'text'] = payload.negative_prompt + elif node.type == 'width': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "width" - ] = payload.width - elif node.type == "height": + workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width + elif node.type == 'height': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "height" - ] = payload.height - elif node.type == "n": + workflow[node_id]['inputs'][node.key if node.key else 'height'] = payload.height + elif node.type == 'n': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "batch_size" - ] = payload.n - elif node.type == "steps": + workflow[node_id]['inputs'][node.key if node.key else 'batch_size'] = payload.n + elif node.type == 'steps': for node_id in node.node_ids: - workflow[node_id]["inputs"][ - node.key if node.key else "steps" - ] = payload.steps - elif node.type == "seed": - seed = ( - payload.seed - if payload.seed - else random.randint(0, 1125899906842624) - ) + workflow[node_id]['inputs'][node.key if node.key else 'steps'] = payload.steps + elif node.type == 'seed': + seed = payload.seed if payload.seed else random.randint(0, 1125899906842624) for node_id in node.node_ids: - workflow[node_id]["inputs"][node.key] = seed + workflow[node_id]['inputs'][node.key] = seed else: for node_id in node.node_ids: - workflow[node_id]["inputs"][node.key] = node.value + workflow[node_id]['inputs'][node.key] = node.value try: ws = websocket.WebSocket() - headers = {"Authorization": f"Bearer {api_key}"} - ws.connect(f"{ws_url}/ws?clientId={client_id}", header=headers) - log.info("WebSocket connection established.") + headers = {'Authorization': f'Bearer {api_key}'} + ws.connect(f'{ws_url}/ws?clientId={client_id}', header=headers) + log.info('WebSocket connection established.') except Exception as e: - log.exception(f"Failed to connect to WebSocket server: {e}") + log.exception(f'Failed to connect to WebSocket server: {e}') return None try: - log.info("Sending workflow to WebSocket server.") - log.info(f"Workflow: {workflow}") - images = await asyncio.to_thread( - get_images, ws, workflow, client_id, base_url, api_key - ) + log.info('Sending workflow to WebSocket server.') + log.info(f'Workflow: {workflow}') + images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url, api_key) except Exception as e: - log.exception(f"Error while receiving images: {e}") + log.exception(f'Error while receiving images: {e}') images = None ws.close() diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index 26a525fc0b..49b7973c57 100644 --- a/backend/open_webui/utils/logger.py +++ b/backend/open_webui/utils/logger.py @@ -23,7 +23,7 @@ from loguru import Message, Record -def stdout_format(record: "Record") -> str: +def stdout_format(record: 'Record') -> str: """ Generates a formatted string for log records that are output to the console. This format includes a timestamp, log level, source location (module, function, and line), the log message, and any extra data (serialized as JSON). @@ -32,39 +32,39 @@ def stdout_format(record: "Record") -> str: Returns: str: A formatted log string intended for stdout. """ - if record["extra"]: - record["extra"]["extra_json"] = json.dumps(record["extra"]) - extra_format = " - {extra[extra_json]}" + if record['extra']: + record['extra']['extra_json'] = json.dumps(record['extra']) + extra_format = ' - {extra[extra_json]}' else: - extra_format = "" + extra_format = '' return ( - "{time:YYYY-MM-DD HH:mm:ss.SSS} | " - "{level: <8} | " - "{name}:{function}:{line} - " - "{message}" + extra_format + "\n{exception}" + '{time:YYYY-MM-DD HH:mm:ss.SSS} | ' + '{level: <8} | ' + '{name}:{function}:{line} - ' + '{message}' + extra_format + '\n{exception}' ) -def _json_sink(message: "Message") -> None: +def _json_sink(message: 'Message') -> None: """Write log records as single-line JSON to stdout. Used as a Loguru sink when LOG_FORMAT is set to "json". """ record = message.record log_entry = { - "ts": record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", - "level": _LEVEL_MAP.get(record["level"].name, record["level"].name.lower()), - "msg": record["message"], - "caller": f"{record['name']}:{record['function']}:{record['line']}", + 'ts': record['time'].strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', + 'level': _LEVEL_MAP.get(record['level'].name, record['level'].name.lower()), + 'msg': record['message'], + 'caller': f'{record["name"]}:{record["function"]}:{record["line"]}', } - if record["extra"]: - log_entry["extra"] = record["extra"] + if record['extra']: + log_entry['extra'] = record['extra'] - if record["exception"] is not None: - log_entry["error"] = "".join(record["exception"].format_exception()).rstrip() + if record['exception'] is not None: + log_entry['error'] = ''.join(record['exception'].format_exception()).rstrip() - sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + "\n") + sys.stdout.write(json.dumps(log_entry, ensure_ascii=False, default=str) + '\n') sys.stdout.flush() @@ -90,9 +90,7 @@ def emit(self, record): frame = frame.f_back depth += 1 - logger.opt(depth=depth, exception=record.exc_info).bind( - **self._get_extras() - ).log(level, record.getMessage()) + logger.opt(depth=depth, exception=record.exc_info).bind(**self._get_extras()).log(level, record.getMessage()) if ENABLE_OTEL and ENABLE_OTEL_LOGS: from open_webui.utils.telemetry.logs import otel_handler @@ -105,12 +103,12 @@ def _get_extras(self): extras = {} context = trace.get_current_span().get_span_context() if context.is_valid: - extras["trace_id"] = trace.format_trace_id(context.trace_id) - extras["span_id"] = trace.format_span_id(context.span_id) + extras['trace_id'] = trace.format_trace_id(context.trace_id) + extras['span_id'] = trace.format_span_id(context.span_id) return extras -def file_format(record: "Record"): +def file_format(record: 'Record'): """ Formats audit log records into a structured JSON string for file output. @@ -121,22 +119,22 @@ def file_format(record: "Record"): """ audit_data = { - "id": record["extra"].get("id", ""), - "timestamp": int(record["time"].timestamp()), - "user": record["extra"].get("user", dict()), - "audit_level": record["extra"].get("audit_level", ""), - "verb": record["extra"].get("verb", ""), - "request_uri": record["extra"].get("request_uri", ""), - "response_status_code": record["extra"].get("response_status_code", 0), - "source_ip": record["extra"].get("source_ip", ""), - "user_agent": record["extra"].get("user_agent", ""), - "request_object": record["extra"].get("request_object", b""), - "response_object": record["extra"].get("response_object", b""), - "extra": record["extra"].get("extra", {}), + 'id': record['extra'].get('id', ''), + 'timestamp': int(record['time'].timestamp()), + 'user': record['extra'].get('user', dict()), + 'audit_level': record['extra'].get('audit_level', ''), + 'verb': record['extra'].get('verb', ''), + 'request_uri': record['extra'].get('request_uri', ''), + 'response_status_code': record['extra'].get('response_status_code', 0), + 'source_ip': record['extra'].get('source_ip', ''), + 'user_agent': record['extra'].get('user_agent', ''), + 'request_object': record['extra'].get('request_object', b''), + 'response_object': record['extra'].get('response_object', b''), + 'extra': record['extra'].get('extra', {}), } - record["extra"]["file_extra"] = json.dumps(audit_data, default=str) - return "{extra[file_extra]}\n" + record['extra']['file_extra'] = json.dumps(audit_data, default=str) + return '{extra[file_extra]}\n' def start_logger(): @@ -152,10 +150,8 @@ def start_logger(): """ logger.remove() - audit_filter = lambda record: ( - True if ENABLE_AUDIT_STDOUT else "auditable" not in record["extra"] - ) - if LOG_FORMAT == "json": + audit_filter = lambda record: True if ENABLE_AUDIT_STDOUT else 'auditable' not in record['extra'] + if LOG_FORMAT == 'json': logger.add( _json_sink, level=GLOBAL_LOG_LEVEL, @@ -168,24 +164,22 @@ def start_logger(): format=stdout_format, filter=audit_filter, ) - if AUDIT_LOG_LEVEL != "NONE" and ENABLE_AUDIT_LOGS_FILE: + if AUDIT_LOG_LEVEL != 'NONE' and ENABLE_AUDIT_LOGS_FILE: try: logger.add( AUDIT_LOGS_FILE_PATH, - level="INFO", + level='INFO', rotation=AUDIT_LOG_FILE_ROTATION_SIZE, - compression="zip", + compression='zip', format=file_format, - filter=lambda record: record["extra"].get("auditable") is True, + filter=lambda record: record['extra'].get('auditable') is True, ) except Exception as e: - logger.error(f"Failed to initialize audit log file handler: {str(e)}") + logger.error(f'Failed to initialize audit log file handler: {str(e)}') - logging.basicConfig( - handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True - ) + logging.basicConfig(handlers=[InterceptHandler()], level=GLOBAL_LOG_LEVEL, force=True) - for uvicorn_logger_name in ["uvicorn", "uvicorn.error"]: + for uvicorn_logger_name in ['uvicorn', 'uvicorn.error']: uvicorn_logger = logging.getLogger(uvicorn_logger_name) uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL) uvicorn_logger.handlers = [] @@ -195,4 +189,4 @@ def start_logger(): uvicorn_logger.setLevel(GLOBAL_LOG_LEVEL) uvicorn_logger.handlers = [InterceptHandler()] - logger.info(f"GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}") + logger.info(f'GLOBAL_LOG_LEVEL: {GLOBAL_LOG_LEVEL}') diff --git a/backend/open_webui/utils/mcp/client.py b/backend/open_webui/utils/mcp/client.py index 1004536e4d..fbabb390aa 100644 --- a/backend/open_webui/utils/mcp/client.py +++ b/backend/open_webui/utils/mcp/client.py @@ -20,15 +20,15 @@ def create_insecure_httpx_client(headers=None, timeout=None, auth=None): after construction does not affect the underlying transport's SSL context. """ kwargs = { - "follow_redirects": True, - "verify": False, + 'follow_redirects': True, + 'verify': False, } if timeout is not None: - kwargs["timeout"] = timeout + kwargs['timeout'] = timeout if headers is not None: - kwargs["headers"] = headers + kwargs['headers'] = headers if auth is not None: - kwargs["auth"] = auth + kwargs['auth'] = auth return httpx.AsyncClient(**kwargs) @@ -52,13 +52,9 @@ async def connect(self, url: str, headers: Optional[dict] = None): transport = await exit_stack.enter_async_context(self._streams_context) read_stream, write_stream, _ = transport - self._session_context = ClientSession( - read_stream, write_stream - ) # pylint: disable=W0201 + self._session_context = ClientSession(read_stream, write_stream) # pylint: disable=W0201 - self.session = await exit_stack.enter_async_context( - self._session_context - ) + self.session = await exit_stack.enter_async_context(self._session_context) with anyio.fail_after(10): await self.session.initialize() self.exit_stack = exit_stack.pop_all() @@ -68,7 +64,7 @@ async def connect(self, url: str, headers: Optional[dict] = None): async def list_tool_specs(self) -> Optional[dict]: if not self.session: - raise RuntimeError("MCP client is not connected.") + raise RuntimeError('MCP client is not connected.') result = await self.session.list_tools() tools = result.tools @@ -81,26 +77,22 @@ async def list_tool_specs(self) -> Optional[dict]: inputSchema = tool.inputSchema # TODO: handle outputSchema if needed - outputSchema = getattr(tool, "outputSchema", None) + outputSchema = getattr(tool, 'outputSchema', None) - tool_specs.append( - {"name": name, "description": description, "parameters": inputSchema} - ) + tool_specs.append({'name': name, 'description': description, 'parameters': inputSchema}) return tool_specs - async def call_tool( - self, function_name: str, function_args: dict - ) -> Optional[dict]: + async def call_tool(self, function_name: str, function_args: dict) -> Optional[dict]: if not self.session: - raise RuntimeError("MCP client is not connected.") + raise RuntimeError('MCP client is not connected.') result = await self.session.call_tool(function_name, function_args) if not result: - raise Exception("No result returned from MCP tool call.") + raise Exception('No result returned from MCP tool call.') - result_dict = result.model_dump(mode="json") - result_content = result_dict.get("content", {}) + result_dict = result.model_dump(mode='json') + result_content = result_dict.get('content', {}) if result.isError: raise Exception(result_content) @@ -109,24 +101,24 @@ async def call_tool( async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]: if not self.session: - raise RuntimeError("MCP client is not connected.") + raise RuntimeError('MCP client is not connected.') result = await self.session.list_resources(cursor=cursor) if not result: - raise Exception("No result returned from MCP list_resources call.") + raise Exception('No result returned from MCP list_resources call.') result_dict = result.model_dump() - resources = result_dict.get("resources", []) + resources = result_dict.get('resources', []) return resources async def read_resource(self, uri: str) -> Optional[dict]: if not self.session: - raise RuntimeError("MCP client is not connected.") + raise RuntimeError('MCP client is not connected.') result = await self.session.read_resource(uri) if not result: - raise Exception("No result returned from MCP read_resource call.") + raise Exception('No result returned from MCP read_resource call.') result_dict = result.model_dump() return result_dict diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 8bfed7d391..d98d6901cc 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -92,11 +92,13 @@ get_last_user_message_item, get_last_assistant_message, get_system_message, + merge_system_messages, replace_system_message_content, prepend_to_first_user_message_content, convert_logit_bias_input_to_json, get_content_from_message, convert_output_to_messages, + strip_empty_content_blocks, ) from open_webui.utils.tools import ( get_tools, @@ -135,6 +137,7 @@ ENABLE_FORWARD_USER_INFO_HEADERS, FORWARD_SESSION_INFO_HEADER_CHAT_ID, FORWARD_SESSION_INFO_HEADER_MESSAGE_ID, + ENABLE_RESPONSES_API_STATEFUL, ) from open_webui.utils.headers import include_user_info_headers from open_webui.constants import TASKS @@ -143,23 +146,27 @@ log = logging.getLogger(__name__) +# 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 +# inference of the world to come. DEFAULT_REASONING_TAGS = [ - ("", ""), - ("", ""), - ("", ""), - ("", ""), - ("", ""), - ("", ""), - ("<|begin_of_thought|>", "<|end_of_thought|>"), - ("โ—thinkโ–ท", "โ—/thinkโ–ท"), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('<|begin_of_thought|>', '<|end_of_thought|>'), + ('โ—thinkโ–ท', 'โ—/thinkโ–ท'), ] -DEFAULT_SOLUTION_TAGS = [("<|begin_of_solution|>", "<|end_of_solution|>")] -DEFAULT_CODE_INTERPRETER_TAGS = [("", "")] +DEFAULT_SOLUTION_TAGS = [('<|begin_of_solution|>', '<|end_of_solution|>')] +DEFAULT_CODE_INTERPRETER_TAGS = [('', '')] def output_id(prefix: str) -> str: """Generate OR-style ID: prefix + 24-char hex UUID.""" - return f"{prefix}_{uuid4().hex[:24]}" + return f'{prefix}_{uuid4().hex[:24]}' def _split_tool_calls( @@ -196,7 +203,7 @@ def split_json_objects(raw: str) -> list[str]: expanded = [] for tool_call in tool_calls: - arguments = tool_call.get("function", {}).get("arguments", "") + arguments = tool_call.get('function', {}).get('arguments', '') split_arguments = split_json_objects(arguments) if len(split_arguments) <= 1: @@ -204,15 +211,15 @@ def split_json_objects(raw: str) -> list[str]: else: for argument in split_arguments: cloned = copy.deepcopy(tool_call) - cloned["id"] = f"call_{uuid4().hex[:24]}" - cloned["function"]["arguments"] = argument + cloned['id'] = f'call_{uuid4().hex[:24]}' + cloned['function']['arguments'] = argument expanded.append(cloned) return expanded def get_citation_source_from_tool_result( - tool_name: str, tool_params: dict, tool_result: str, tool_id: str = "" + tool_name: str, tool_params: dict, tool_result: str, tool_id: str = '' ) -> list[dict]: """ Parse a tool's result and convert it to source dicts for citation display. @@ -224,15 +231,15 @@ def get_citation_source_from_tool_result( Returns a list of sources (usually one, but query_knowledge_files may return multiple). """ - _EXPECTS_LIST = {"search_web", "query_knowledge_files"} - _EXPECTS_DICT = {"view_knowledge_file"} + _EXPECTS_LIST = {'search_web', 'query_knowledge_files'} + _EXPECTS_DICT = {'view_knowledge_file', 'view_file'} try: try: tool_result = json.loads(tool_result) except (json.JSONDecodeError, TypeError): pass # keep tool_result as-is (e.g. fetch_url returns plain text) - if isinstance(tool_result, dict) and "error" in tool_result: + if isinstance(tool_result, dict) and 'error' in tool_result: return [] # Validate tool_result type based on what the branch expects @@ -241,83 +248,79 @@ def get_citation_source_from_tool_result( elif tool_name in _EXPECTS_DICT and not isinstance(tool_result, dict): return [] - if tool_name == "search_web": + if tool_name == 'search_web': # Parse JSON array: [{"title": "...", "link": "...", "snippet": "..."}] results = tool_result documents = [] metadata = [] for result in results: - title = result.get("title", "") - link = result.get("link", "") - snippet = result.get("snippet", "") + title = result.get('title', '') + link = result.get('link', '') + snippet = result.get('snippet', '') - documents.append(f"{title}\n{snippet}") + documents.append(f'{title}\n{snippet}') metadata.append( { - "source": link, - "name": title, - "url": link, + 'source': link, + 'name': title, + 'url': link, } ) return [ { - "source": {"name": "search_web", "id": "search_web"}, - "document": documents, - "metadata": metadata, + 'source': {'name': 'search_web', 'id': 'search_web'}, + 'document': documents, + 'metadata': metadata, } ] - elif tool_name == "view_knowledge_file": + elif tool_name in ('view_knowledge_file', 'view_file'): file_data = tool_result - filename = file_data.get("filename", "Unknown File") - file_id = file_data.get("id", "") - knowledge_name = file_data.get("knowledge_name", "") + filename = file_data.get('filename', 'Unknown File') + file_id = file_data.get('id', '') + knowledge_name = file_data.get('knowledge_name', '') return [ { - "source": { - "id": file_id, - "name": filename, - "type": "file", + 'source': { + 'id': file_id, + 'name': filename, + 'type': 'file', }, - "document": [file_data.get("content", "")], - "metadata": [ + 'document': [file_data.get('content', '')], + 'metadata': [ { - "file_id": file_id, - "name": filename, - "source": filename, - **( - {"knowledge_name": knowledge_name} - if knowledge_name - else {} - ), + 'file_id': file_id, + 'name': filename, + 'source': filename, + **({'knowledge_name': knowledge_name} if knowledge_name else {}), } ], } ] - elif tool_name == "fetch_url": - url = tool_params.get("url", "") + elif tool_name == 'fetch_url': + url = tool_params.get('url', '') content = tool_result if isinstance(tool_result, str) else str(tool_result) - snippet = content[:500] + ("..." if len(content) > 500 else "") + snippet = content[:500] + ('...' if len(content) > 500 else '') return [ { - "source": {"name": url or "fetch_url", "id": url or "fetch_url"}, - "document": [snippet], - "metadata": [ + 'source': {'name': url or 'fetch_url', 'id': url or 'fetch_url'}, + 'document': [snippet], + 'metadata': [ { - "source": url, - "name": url, - "url": url, + 'source': url, + 'name': url, + 'url': url, } ], } ] - elif tool_name == "query_knowledge_files": + elif tool_name == 'query_knowledge_files': chunks = tool_result # Group chunks by source for better citation display @@ -325,33 +328,33 @@ def get_citation_source_from_tool_result( sources_by_file = {} for chunk in chunks: - source_name = chunk.get("source", "Unknown") - file_id = chunk.get("file_id", "") - note_id = chunk.get("note_id", "") - chunk_type = chunk.get("type", "file") - content = chunk.get("content", "") + source_name = chunk.get('source', 'Unknown') + file_id = chunk.get('file_id', '') + note_id = chunk.get('note_id', '') + chunk_type = chunk.get('type', 'file') + content = chunk.get('content', '') # Use file_id or note_id as the key key = file_id or note_id or source_name if key not in sources_by_file: sources_by_file[key] = { - "source": { - "id": file_id or note_id, - "name": source_name, - "type": chunk_type, + 'source': { + 'id': file_id or note_id, + 'name': source_name, + 'type': chunk_type, }, - "document": [], - "metadata": [], + 'document': [], + 'metadata': [], } - sources_by_file[key]["document"].append(content) - sources_by_file[key]["metadata"].append( + sources_by_file[key]['document'].append(content) + sources_by_file[key]['metadata'].append( { - "file_id": file_id, - "name": source_name, - "source": source_name, - **({"note_id": note_id} if note_id else {}), + 'file_id': file_id, + 'name': source_name, + 'source': source_name, + **({'note_id': note_id} if note_id else {}), } ) @@ -366,36 +369,34 @@ def get_citation_source_from_tool_result( # Fallback for other tools return [ { - "source": { - "name": tool_name, - "type": "tool", - "id": tool_id or tool_name, + 'source': { + 'name': tool_name, + 'type': 'tool', + 'id': tool_id or tool_name, }, - "document": [str(tool_result)], - "metadata": [{"source": tool_name, "name": tool_name}], + 'document': [str(tool_result)], + 'metadata': [{'source': tool_name, 'name': tool_name}], } ] except Exception as e: - log.exception(f"Error parsing tool result for {tool_name}: {e}") + log.exception(f'Error parsing tool result for {tool_name}: {e}') return [ { - "source": {"name": tool_name, "type": "tool"}, - "document": [str(tool_result)], - "metadata": [{"source": tool_name}], + 'source': {'name': tool_name, 'type': 'tool'}, + 'document': [str(tool_result)], + 'metadata': [{'source': tool_name}], } ] def split_content_and_whitespace(content): content_stripped = content.rstrip() - original_whitespace = ( - content[len(content_stripped) :] if len(content) > len(content_stripped) else "" - ) + original_whitespace = content[len(content_stripped) :] if len(content) > len(content_stripped) else '' return content_stripped, original_whitespace def is_opening_code_block(content): - backtick_segments = content.split("```") + backtick_segments = content.split('```') # Even number of segments means the last backticks are opening a new block return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0 @@ -405,128 +406,119 @@ def serialize_output(output: list) -> str: Convert OR-aligned output items to HTML for display. For LLM consumption, use convert_output_to_messages() instead. """ - content = "" + content = '' # First pass: collect function_call_output items by call_id for lookup tool_outputs = {} for item in output: - if item.get("type") == "function_call_output": - tool_outputs[item.get("call_id")] = item + if item.get('type') == 'function_call_output': + tool_outputs[item.get('call_id')] = item # Second pass: render items in order for idx, item in enumerate(output): - item_type = item.get("type", "") + item_type = item.get('type', '') - if item_type == "message": - for content_part in item.get("content", []): - if "text" in content_part: - text = content_part.get("text", "").strip() + if item_type == 'message': + for content_part in item.get('content', []): + if 'text' in content_part: + text = content_part.get('text', '').strip() if text: - content = f"{content}{text}\n" + content = f'{content}{text}\n' - elif item_type == "function_call": + elif item_type == 'function_call': # Render tool call inline with its result (if available) - if content and not content.endswith("\n"): - content += "\n" + if content and not content.endswith('\n'): + content += '\n' - call_id = item.get("call_id", "") - name = item.get("name", "") - arguments = item.get("arguments", "") + call_id = item.get('call_id', '') + name = item.get('name', '') + arguments = item.get('arguments', '') result_item = tool_outputs.get(call_id) if result_item: - result_text = "" - for result_output in result_item.get("output", []): - if "text" in result_output: - output_text = result_output.get("text", "") - result_text += ( - str(output_text) - if not isinstance(output_text, str) - else output_text - ) - files = result_item.get("files") - embeds = result_item.get("embeds", "") + result_text = '' + for result_output in result_item.get('output', []): + if 'text' in result_output: + output_text = result_output.get('text', '') + result_text += str(output_text) if not isinstance(output_text, str) else output_text + files = result_item.get('files') + embeds = result_item.get('embeds', '') content += f'
\nTool Executed\n
\n' else: content += f'
\nExecuting...\n
\n' - elif item_type == "function_call_output": + elif item_type == 'function_call_output': # Already handled inline with function_call above pass - elif item_type == "reasoning": - reasoning_content = "" + elif item_type == 'reasoning': + reasoning_content = '' # Check for 'summary' (new structure) or 'content' (legacy/fallback) - source_list = item.get("summary", []) or item.get("content", []) + source_list = item.get('summary', []) or item.get('content', []) for content_part in source_list: - if "text" in content_part: - reasoning_content += content_part.get("text", "") - elif "summary" in content_part: # Handle potential nested logic if any + if 'text' in content_part: + reasoning_content += content_part.get('text', '') + elif 'summary' in content_part: # Handle potential nested logic if any pass reasoning_content = reasoning_content.strip() - duration = item.get("duration") - status = item.get("status", "in_progress") + duration = item.get('duration') + status = item.get('status', 'in_progress') # Infer completion: if this reasoning item is NOT the last item, # render as done (a subsequent item means reasoning is complete) is_last_item = idx == len(output) - 1 - if content and not content.endswith("\n"): - content += "\n" + if content and not content.endswith('\n'): + content += '\n' display = html.escape( - "\n".join( - (f"> {line}" if not line.startswith(">") else line) - for line in reasoning_content.splitlines() + '\n'.join( + (f'> {line}' if not line.startswith('>') else line) for line in reasoning_content.splitlines() ) ) - if status == "completed" or duration is not None or not is_last_item: + if status == 'completed' or duration is not None or not is_last_item: content = f'{content}
\nThought for {duration or 0} seconds\n{display}\n
\n' else: content = f'{content}
\nThinkingโ€ฆ\n{display}\n
\n' - elif item_type == "open_webui:code_interpreter": - content_stripped, original_whitespace = split_content_and_whitespace( - content - ) + elif item_type == 'open_webui:code_interpreter': + content_stripped, original_whitespace = split_content_and_whitespace(content) if is_opening_code_block(content_stripped): - content = content_stripped.rstrip("`").rstrip() + original_whitespace + content = content_stripped.rstrip('`').rstrip() + original_whitespace else: content = content_stripped + original_whitespace - if content and not content.endswith("\n"): - content += "\n" + if content and not content.endswith('\n'): + content += '\n' # Render the code_interpreter item as a
block # so the frontend Collapsible renders "Analyzing..."/"Analyzed". - code = item.get("code", "").strip() - lang = item.get("lang", "python") - status = item.get("status", "in_progress") - duration = item.get("duration") + code = item.get('code', '').strip() + lang = item.get('lang', 'python') + status = item.get('status', 'in_progress') + duration = item.get('duration') is_last_item = idx == len(output) - 1 # Build inner content: code block - display = "" + display = '' if code: - display = f"```{lang}\n{code}\n```" + display = f'```{lang}\n{code}\n```' # Build output attribute as HTML-escaped JSON for CodeBlock.svelte - ci_output = item.get("output") - output_attr = "" + ci_output = item.get('output') + output_attr = '' if ci_output: if isinstance(ci_output, dict): output_json = json.dumps(ci_output, ensure_ascii=False) else: - output_json = json.dumps( - {"result": str(ci_output)}, ensure_ascii=False - ) + output_json = json.dumps({'result': str(ci_output)}, ensure_ascii=False) output_attr = f' output="{html.escape(output_json)}"' - if status == "completed" or duration is not None or not is_last_item: + if status == 'completed' or duration is not None or not is_last_item: content += f'
\nAnalyzed\n{display}\n
\n' else: content += f'
\nAnalyzingโ€ฆ\n{display}\n
\n' @@ -575,19 +567,19 @@ def handle_responses_streaming_event( # Note: treating current_output as immutable, but avoiding full deepcopy for perf. # We will shallow copy only if we need to modify the list structure or items. - event_type = data.get("type", "") + event_type = data.get('type', '') - if event_type == "response.output_item.added": - item = data.get("item", {}) + if event_type == 'response.output_item.added': + item = data.get('item', {}) if item: new_output = list(current_output) new_output.append(item) return new_output, None return current_output, None - elif event_type == "response.content_part.added": - part = data.get("part", {}) - output_index = data.get("output_index", len(current_output) - 1) + elif event_type == 'response.content_part.added': + part = data.get('part', {}) + output_index = data.get('output_index', len(current_output) - 1) if current_output and 0 <= output_index < len(current_output): new_output = list(current_output) @@ -595,83 +587,83 @@ def handle_responses_streaming_event( item = new_output[output_index].copy() new_output[output_index] = item - if "content" not in item: - item["content"] = [] + if 'content' not in item: + item['content'] = [] else: # Copy content list - item["content"] = list(item["content"]) + item['content'] = list(item['content']) - if item.get("type") == "reasoning": + if item.get('type') == 'reasoning': # Reasoning items should not have content parts pass else: - item["content"].append(part) + item['content'].append(part) return new_output, None return current_output, None - elif event_type == "response.reasoning_summary_part.added": - part = data.get("part", {}) - output_index = data.get("output_index", len(current_output) - 1) + elif event_type == 'response.reasoning_summary_part.added': + part = data.get('part', {}) + output_index = data.get('output_index', len(current_output) - 1) if current_output and 0 <= output_index < len(current_output): new_output = list(current_output) item = new_output[output_index].copy() new_output[output_index] = item - if "summary" not in item: - item["summary"] = [] + if 'summary' not in item: + item['summary'] = [] else: - item["summary"] = list(item["summary"]) + item['summary'] = list(item['summary']) - item["summary"].append(part) + item['summary'].append(part) return new_output, None return current_output, None - elif event_type.startswith("response.") and event_type.endswith(".delta"): + elif event_type.startswith('response.') and event_type.endswith('.delta'): # Generic Delta Handling - parts = event_type.split(".") + parts = event_type.split('.') if len(parts) >= 3: delta_type = parts[1] - delta = data.get("delta", "") + delta = data.get('delta', '') - output_index = data.get("output_index", len(current_output) - 1) + output_index = data.get('output_index', len(current_output) - 1) if current_output and 0 <= output_index < len(current_output): new_output = list(current_output) item = new_output[output_index].copy() new_output[output_index] = item - item_type = item.get("type", "") + item_type = item.get('type', '') # Determine target field and object based on delta_type and item_type - if delta_type == "function_call_arguments": - key = "arguments" - if item_type == "function_call": + if delta_type == 'function_call_arguments': + key = 'arguments' + if item_type == 'function_call': # Function call args are usually strings - item[key] = item.get(key, "") + str(delta) + item[key] = item.get(key, '') + str(delta) else: # Generic handling, refined by item type below pass - if item_type == "message": + if item_type == 'message': # Message items: "text"/"output_text" -> "text" # "reasoning_text" -> Skipped (should use reasoning item) - if delta_type in ["text", "output_text"]: - key = "text" - elif delta_type in ["reasoning_text", "reasoning_summary_text"]: + if delta_type in ['text', 'output_text']: + key = 'text' + elif delta_type in ['reasoning_text', 'reasoning_summary_text']: # Skip reasoning updates for message items return new_output, None else: key = delta_type - content_index = data.get("content_index", 0) - if "content" not in item: - item["content"] = [] + content_index = data.get('content_index', 0) + if 'content' not in item: + item['content'] = [] else: - item["content"] = list(item["content"]) - content_list = item["content"] + item['content'] = list(item['content']) + content_list = item['content'] while len(content_list) <= content_index: - content_list.append({"type": "text", "text": ""}) + content_list.append({'type': 'text', 'text': ''}) # Copy the part to mutate it part = content_list[content_index].copy() @@ -680,55 +672,53 @@ def handle_responses_streaming_event( current_val = part.get(key) if current_val is None: # Initialize based on delta type - current_val = {} if isinstance(delta, dict) else "" + current_val = {} if isinstance(delta, dict) else '' part[key] = deep_merge(current_val, delta) - elif item_type == "reasoning": + elif item_type == 'reasoning': # Reasoning items: "reasoning_text"/"reasoning_summary_text" -> "text" # "text"/"output_text" -> Skipped (should use message item) - if delta_type == "reasoning_summary_text": + if delta_type == 'reasoning_summary_text': # Summary updates -> item['summary'] - key = "text" - summary_index = data.get("summary_index", 0) - if "summary" not in item: - item["summary"] = [] + key = 'text' + summary_index = data.get('summary_index', 0) + if 'summary' not in item: + item['summary'] = [] else: - item["summary"] = list(item["summary"]) - summary_list = item["summary"] + item['summary'] = list(item['summary']) + summary_list = item['summary'] while len(summary_list) <= summary_index: - summary_list.append( - {"type": "summary_text", "text": ""} - ) + summary_list.append({'type': 'summary_text', 'text': ''}) part = summary_list[summary_index].copy() summary_list[summary_index] = part - target_val = part.get(key, "") + target_val = part.get(key, '') part[key] = deep_merge(target_val, delta) - elif delta_type == "reasoning_text": + elif delta_type == 'reasoning_text': # Reasoning body updates -> item['content'] - key = "text" - content_index = data.get("content_index", 0) - if "content" not in item: - item["content"] = [] + key = 'text' + content_index = data.get('content_index', 0) + if 'content' not in item: + item['content'] = [] else: - item["content"] = list(item["content"]) - content_list = item["content"] + item['content'] = list(item['content']) + content_list = item['content'] while len(content_list) <= content_index: # Reasoning content parts default to text - content_list.append({"type": "text", "text": ""}) + content_list.append({'type': 'text', 'text': ''}) part = content_list[content_index].copy() content_list[content_index] = part - target_val = part.get(key, "") + target_val = part.get(key, '') part[key] = deep_merge(target_val, delta) - elif delta_type in ["text", "output_text"]: + elif delta_type in ['text', 'output_text']: return new_output, None else: # Fallback just in case other deltas target reasoning? @@ -736,109 +726,104 @@ def handle_responses_streaming_event( else: # Fallback for other item types - if delta_type in ["text", "output_text"]: - key = "text" + if delta_type in ['text', 'output_text']: + key = 'text' else: key = delta_type current_val = item.get(key) if current_val is None: - current_val = {} if isinstance(delta, dict) else "" + current_val = {} if isinstance(delta, dict) else '' item[key] = deep_merge(current_val, delta) return new_output, None - elif event_type.startswith("response.") and event_type.endswith(".done"): + elif event_type.startswith('response.') and event_type.endswith('.done'): # Delta Events: response.content_part.done, response.text.done, etc. - parts = event_type.split(".") + parts = event_type.split('.') if len(parts) >= 3: type_name = parts[1] # 1. Handle specific Delta "done" signals - if type_name == "content_part": + if type_name == 'content_part': # "Signaling that no further changes will occur to a content part" # If payloads contains the full part, we could update it. # Usually purely signaling in standard implementation, but we check payload. - part = data.get("part") - output_index = data.get("output_index", len(current_output) - 1) + part = data.get('part') + output_index = data.get('output_index', len(current_output) - 1) if part and current_output and 0 <= output_index < len(current_output): new_output = list(current_output) item = new_output[output_index].copy() new_output[output_index] = item - if "content" in item: - item["content"] = list(item["content"]) - content_index = data.get( - "content_index", len(item["content"]) - 1 - ) - if 0 <= content_index < len(item["content"]): - item["content"][content_index] = part + if 'content' in item: + item['content'] = list(item['content']) + content_index = data.get('content_index', len(item['content']) - 1) + if 0 <= content_index < len(item['content']): + item['content'][content_index] = part return new_output, {} return current_output, None - elif type_name == "reasoning_summary_part": - part = data.get("part") - output_index = data.get("output_index", len(current_output) - 1) + elif type_name == 'reasoning_summary_part': + part = data.get('part') + output_index = data.get('output_index', len(current_output) - 1) if part and current_output and 0 <= output_index < len(current_output): new_output = list(current_output) item = new_output[output_index].copy() new_output[output_index] = item - if "summary" in item: - item["summary"] = list(item["summary"]) - summary_index = data.get( - "summary_index", len(item["summary"]) - 1 - ) - if 0 <= summary_index < len(item["summary"]): - item["summary"][summary_index] = part + if 'summary' in item: + item['summary'] = list(item['summary']) + summary_index = data.get('summary_index', len(item['summary']) - 1) + if 0 <= summary_index < len(item['summary']): + item['summary'][summary_index] = part return new_output, {} return current_output, None # 2. Skip Output Item done (handled specifically below) - if type_name == "output_item": + if type_name == 'output_item': pass # 3. Generic Field Done (text.done, audio.done) - elif type_name not in ["completed", "failed"]: - output_index = data.get("output_index", len(current_output) - 1) + elif type_name not in ['completed', 'failed']: + output_index = data.get('output_index', len(current_output) - 1) if current_output and 0 <= output_index < len(current_output): - key = ( - "text" + 'text' if type_name in [ - "text", - "output_text", - "reasoning_text", - "reasoning_summary_text", + 'text', + 'output_text', + 'reasoning_text', + 'reasoning_summary_text', ] else type_name ) - if type_name == "function_call_arguments": - key = "arguments" + if type_name == 'function_call_arguments': + key = 'arguments' if key in data: final_value = data[key] new_output = list(current_output) item = new_output[output_index].copy() new_output[output_index] = item - item_type = item.get("type", "") - - if type_name == "function_call_arguments": - if item_type == "function_call": - item["arguments"] = final_value - elif item_type == "message": - content_index = data.get("content_index", 0) - if "content" in item: - item["content"] = list(item["content"]) - if len(item["content"]) > content_index: - part = item["content"][content_index].copy() - item["content"][content_index] = part + item_type = item.get('type', '') + + if type_name == 'function_call_arguments': + if item_type == 'function_call': + item['arguments'] = final_value + elif item_type == 'message': + content_index = data.get('content_index', 0) + if 'content' in item: + item['content'] = list(item['content']) + if len(item['content']) > content_index: + part = item['content'][content_index].copy() + item['content'][content_index] = part part[key] = final_value - elif item_type == "reasoning": - item["status"] = "completed" + elif item_type == 'reasoning': + item['status'] = 'completed' else: item[key] = final_value @@ -846,10 +831,10 @@ def handle_responses_streaming_event( return current_output, None - elif event_type == "response.output_item.done": + elif event_type == 'response.output_item.done': # Delta Event: Output item complete - item = data.get("item") - output_index = data.get("output_index", len(current_output) - 1) + item = data.get('item') + output_index = data.get('output_index', len(current_output) - 1) new_output = list(current_output) if item and 0 <= output_index < len(current_output): @@ -858,60 +843,57 @@ def handle_responses_streaming_event( new_output.append(item) return new_output, {} - elif event_type == "response.completed": + elif event_type == 'response.completed': # State Machine Event: Completed - response_data = data.get("response", {}) - final_output = response_data.get("output") + response_data = data.get('response', {}) + final_output = response_data.get('output') new_output = final_output if final_output is not None else current_output # Ensure reasoning items are marked as completed in the final output if new_output: for item in new_output: - if ( - item.get("type") == "reasoning" - and item.get("status") != "completed" - ): - item["status"] = "completed" + if item.get('type') == 'reasoning' and item.get('status') != 'completed': + item['status'] = 'completed' - return new_output, {"usage": response_data.get("usage"), "done": True} + return new_output, { + 'usage': response_data.get('usage'), + 'done': True, + 'response_id': response_data.get('id'), + } - elif event_type == "response.in_progress": + elif event_type == 'response.in_progress': # State Machine Event: In Progress # We could extract metadata if needed, but for now just acknowledge iteration return current_output, None - elif event_type == "response.failed": + elif event_type == 'response.failed': # State Machine Event: Failed - error = data.get("response", {}).get("error", {}) - return current_output, {"error": error} + error = data.get('response', {}).get('error', {}) + return current_output, {'error': error} else: return current_output, None -def get_source_context( - sources: list, source_ids: dict = None, include_content: bool = True -) -> str: +def get_source_context(sources: list, source_ids: dict = None, include_content: bool = True) -> str: """ Build tag context string from citation sources. """ - context_string = "" + context_string = '' if source_ids is None: source_ids = {} for source in sources: - for doc, meta in zip(source.get("document", []), source.get("metadata", [])): - source_id = ( - meta.get("source") or source.get("source", {}).get("id") or "N/A" - ) + for doc, meta in zip(source.get('document', []), source.get('metadata', [])): + source_id = meta.get('source') or source.get('source', {}).get('id') or 'N/A' if source_id not in source_ids: source_ids[source_id] = len(source_ids) + 1 - src_name = source.get("source", {}).get("name") - body = doc if include_content else "" + src_name = source.get('source', {}).get('name') + body = doc if include_content else '' context_string += ( f'{body}\n" + + (f' name="{src_name}"' if src_name else '') + + f'>{body}\n' ) return context_string @@ -964,40 +946,50 @@ def process_tool_result( user=None, ): tool_result_embeds = [] - EXTERNAL_TOOL_TYPES = ("external", "action", "terminal") + EXTERNAL_TOOL_TYPES = ('external', 'action', 'terminal') + + # Support (HTMLResponse, result_context) tuples: the optional second + # element lets tool authors provide the LLM with actionable context + # about the generated embed instead of the generic fallback message. + result_context = None + if isinstance(tool_result, tuple) and len(tool_result) == 2 and isinstance(tool_result[0], HTMLResponse): + tool_result, result_context = tool_result if isinstance(tool_result, HTMLResponse): - content_disposition = tool_result.headers.get("Content-Disposition", "") - if "inline" in content_disposition: - content = tool_result.body.decode("utf-8", "replace") + content_disposition = tool_result.headers.get('Content-Disposition', '') + if 'inline' in content_disposition: + content = tool_result.body.decode('utf-8', 'replace') tool_result_embeds.append(content) if 200 <= tool_result.status_code < 300: - tool_result = { - "status": "success", - "code": "ui_component", - "message": f"{tool_function_name}: Embedded UI result is active and visible to the user.", - } + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } elif 400 <= tool_result.status_code < 500: tool_result = { - "status": "error", - "code": "ui_component", - "message": f"{tool_function_name}: Client error {tool_result.status_code} from embedded UI result.", + 'status': 'error', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Client error {tool_result.status_code} from embedded UI result.', } elif 500 <= tool_result.status_code < 600: tool_result = { - "status": "error", - "code": "ui_component", - "message": f"{tool_function_name}: Server error {tool_result.status_code} from embedded UI result.", + 'status': 'error', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Server error {tool_result.status_code} from embedded UI result.', } else: tool_result = { - "status": "error", - "code": "ui_component", - "message": f"{tool_function_name}: Unexpected status code {tool_result.status_code} from embedded UI result.", + 'status': 'error', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Unexpected status code {tool_result.status_code} from embedded UI result.', } else: - tool_result = tool_result.body.decode("utf-8", "replace") + tool_result = tool_result.body.decode('utf-8', 'replace') elif (tool_type in EXTERNAL_TOOL_TYPES and isinstance(tool_result, tuple)) or ( direct_tool and isinstance(tool_result, list) and len(tool_result) == 2 @@ -1013,84 +1005,108 @@ def process_tool_result( if tool_response_headers and isinstance(tool_response_headers, dict): content_disposition = tool_response_headers.get( - "Content-Disposition", - tool_response_headers.get("content-disposition", ""), + 'Content-Disposition', + tool_response_headers.get('content-disposition', ''), ) - if "inline" in content_disposition: + if 'inline' in content_disposition: content_type = tool_response_headers.get( - "Content-Type", - tool_response_headers.get("content-type", ""), + 'Content-Type', + tool_response_headers.get('content-type', ''), ) location = tool_response_headers.get( - "Location", - tool_response_headers.get("location", ""), + 'Location', + tool_response_headers.get('location', ''), ) - if "text/html" in content_type: + if 'text/html' in content_type: + # Support (html_content, result_context) nested tuple + result_context = None + html_content = tool_result + if isinstance(tool_result, (tuple, list)) and len(tool_result) == 2: + html_content, result_context = tool_result + # Display as iframe embed - tool_result_embeds.append(tool_result) - tool_result = { - "status": "success", - "code": "ui_component", - "message": f"{tool_function_name}: Embedded UI result is active and visible to the user.", - } + tool_result_embeds.append(html_content) + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } elif location: + # Support (html_content, result_context) nested tuple for location embeds + result_context = None + if isinstance(tool_result, (tuple, list)) and len(tool_result) == 2: + _, result_context = tool_result + tool_result_embeds.append(location) - tool_result = { - "status": "success", - "code": "ui_component", - "message": f"{tool_function_name}: Embedded UI result is active and visible to the user.", - } + if result_context is not None and isinstance(result_context, (str, dict, list)): + tool_result = result_context + else: + tool_result = { + 'status': 'success', + 'code': 'ui_component', + 'message': f'{tool_function_name}: Embedded UI result is active and visible to the user.', + } tool_result_files = [] + # Detect base64 image data URIs from tool results (e.g. binary image + # responses from execute_tool_server). Move the data URI to + # tool_result_files and replace tool_result with a text summary. + if isinstance(tool_result, str) and tool_result.startswith('data:image/'): + tool_result_files.append({'type': 'image', 'url': tool_result}) + tool_result = f'{tool_function_name}: Image file read successfully.' + if isinstance(tool_result, list): - if tool_type == "mcp": # MCP + if tool_type == 'mcp': # MCP tool_response = [] for item in tool_result: if isinstance(item, dict): - if item.get("type") == "text": - text = item.get("text", "") + if item.get('type') == 'text': + text = item.get('text', '') if isinstance(text, str): try: text = json.loads(text) except json.JSONDecodeError: pass tool_response.append(text) - elif item.get("type") in ["image", "audio"]: + elif item.get('type') in ['image', 'audio']: file_url = get_file_url_from_base64( request, - f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}", + f'data:{item.get("mimeType")};base64,{item.get("data", item.get("blob", ""))}', { - "chat_id": metadata.get("chat_id", None), - "message_id": metadata.get("message_id", None), - "session_id": metadata.get("session_id", None), - "result": item, + 'chat_id': metadata.get('chat_id', None), + 'message_id': metadata.get('message_id', None), + 'session_id': metadata.get('session_id', None), + 'result': item, }, user, ) tool_result_files.append( { - "type": item.get("type", "data"), - "url": file_url, + 'type': item.get('type', 'data'), + 'url': file_url, } ) tool_result = tool_response[0] if len(tool_response) == 1 else tool_response else: # OpenAPI for item in tool_result: - if isinstance(item, str) and item.startswith("data:"): + if isinstance(item, str) and item.startswith('data:'): tool_result_files.append( { - "type": "data", - "content": item, + 'type': 'data', + 'content': item, } ) tool_result.remove(item) if isinstance(tool_result, list): - tool_result = {"results": tool_result} + tool_result = {'results': tool_result} if isinstance(tool_result, dict) or isinstance(tool_result, list): tool_result = json.dumps(tool_result, indent=2, ensure_ascii=False) @@ -1101,11 +1117,7 @@ def process_tool_result( if tool_result is not None and not isinstance(tool_result, str): if isinstance(tool_result, tuple): # execute_tool_server returns (data, headers); unpack the data part - tool_result = ( - json.dumps(tool_result[0], indent=2, ensure_ascii=False) - if len(tool_result) > 0 - else "" - ) + tool_result = json.dumps(tool_result[0], indent=2, ensure_ascii=False) if len(tool_result) > 0 else '' else: tool_result = str(tool_result) @@ -1127,8 +1139,8 @@ async def terminal_event_handler( if not event_emitter: return - if tool_function_name == "display_file": - path = tool_function_params.get("path", "") + if tool_function_name == 'display_file': + path = tool_function_params.get('path', '') if not path: return # Only emit if the file actually exists @@ -1138,30 +1150,30 @@ async def terminal_event_handler( parsed = json.loads(parsed) except (json.JSONDecodeError, TypeError): pass - if isinstance(parsed, dict) and parsed.get("exists") is False: + if isinstance(parsed, dict) and parsed.get('exists') is False: return await event_emitter( { - "type": f"terminal:{tool_function_name}", - "data": {"path": path}, + 'type': f'terminal:{tool_function_name}', + 'data': {'path': path}, } ) - elif tool_function_name in ("write_file", "replace_file_content"): - path = tool_function_params.get("path", "") + elif tool_function_name in ('write_file', 'replace_file_content'): + path = tool_function_params.get('path', '') if not path: return await event_emitter( { - "type": f"terminal:{tool_function_name}", - "data": {"path": path}, + 'type': f'terminal:{tool_function_name}', + 'data': {'path': path}, } ) - elif tool_function_name == "run_command": + elif tool_function_name == 'run_command': await event_emitter( { - "type": "terminal:run_command", - "data": {}, + 'type': 'terminal:run_command', + 'data': {}, } ) @@ -1171,53 +1183,48 @@ async def chat_completion_tools_handler( ) -> tuple[dict, dict]: async def get_content_from_response(response) -> Optional[str]: content = None - if hasattr(response, "body_iterator"): + if hasattr(response, 'body_iterator'): async for chunk in response.body_iterator: - data = json.loads(chunk.decode("utf-8", "replace")) - content = data["choices"][0]["message"]["content"] + data = json.loads(chunk.decode('utf-8', 'replace')) + content = data['choices'][0]['message']['content'] # Cleanup any remaining background tasks if necessary if response.background is not None: await response.background() else: - content = response["choices"][0]["message"]["content"] + content = response['choices'][0]['message']['content'] return content def get_tools_function_calling_payload(messages, task_model_id, content): user_message = get_last_user_message(messages) - if user_message and messages and messages[-1]["role"] == "user": + if user_message and messages and messages[-1]['role'] == 'user': # Remove the last user message to avoid duplication messages = messages[:-1] recent_messages = messages[-4:] if len(messages) > 4 else messages - chat_history = "\n".join( - f"{message['role'].upper()}: \"\"\"{get_content_from_message(message)}\"\"\"" - for message in recent_messages + chat_history = '\n'.join( + f'{message["role"].upper()}: """{get_content_from_message(message)}"""' for message in recent_messages ) - prompt = ( - f"History:\n{chat_history}\nQuery: {user_message}" - if chat_history - else f"Query: {user_message}" - ) + prompt = f'History:\n{chat_history}\nQuery: {user_message}' if chat_history else f'Query: {user_message}' return { - "model": task_model_id, - "messages": [ - {"role": "system", "content": content}, - {"role": "user", "content": prompt}, + 'model': task_model_id, + 'messages': [ + {'role': 'system', 'content': content}, + {'role': 'user', 'content': prompt}, ], - "stream": False, - "metadata": {"task": str(TASKS.FUNCTION_CALLING)}, + 'stream': False, + 'metadata': {'task': str(TASKS.FUNCTION_CALLING)}, } - event_caller = extra_params["__event_call__"] - event_emitter = extra_params["__event_emitter__"] - metadata = extra_params["__metadata__"] + event_caller = extra_params['__event_call__'] + event_emitter = extra_params['__event_emitter__'] + metadata = extra_params['__metadata__'] task_model_id = get_task_model_id( - body["model"], + body['model'], request.app.state.config.TASK_MODEL, request.app.state.config.TASK_MODEL_EXTERNAL, models, @@ -1226,97 +1233,85 @@ def get_tools_function_calling_payload(messages, task_model_id, content): skip_files = False sources = [] - specs = [tool["spec"] for tool in tools.values()] + specs = [tool['spec'] for tool in tools.values()] tools_specs = json.dumps(specs, ensure_ascii=False) - if request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE != "": + if request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE != '': template = request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE else: template = DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE - tools_function_calling_prompt = tools_function_calling_generation_template( - template, tools_specs - ) - payload = get_tools_function_calling_payload( - body["messages"], task_model_id, tools_function_calling_prompt - ) + tools_function_calling_prompt = tools_function_calling_generation_template(template, tools_specs) + payload = get_tools_function_calling_payload(body['messages'], task_model_id, tools_function_calling_prompt) try: response = await generate_chat_completion(request, form_data=payload, user=user) - log.debug(f"{response=}") + log.debug(f'{response=}') content = await get_content_from_response(response) - log.debug(f"{content=}") + log.debug(f'{content=}') if not content: return body, {} try: - content = content[content.find("{") : content.rfind("}") + 1] + content = content[content.find('{') : content.rfind('}') + 1] if not content: - raise Exception("No JSON object found in the response") + raise Exception('No JSON object found in the response') result = json.loads(content) async def tool_call_handler(tool_call): nonlocal skip_files - log.debug(f"{tool_call=}") + log.debug(f'{tool_call=}') - tool_function_name = tool_call.get("name", None) + tool_function_name = tool_call.get('name', None) if tool_function_name not in tools: return body, {} - tool_function_params = tool_call.get("parameters", {}) + tool_function_params = tool_call.get('parameters', {}) tool = None - tool_type = "" + tool_type = '' direct_tool = False try: tool = tools[tool_function_name] - tool_type = tool.get("type", "") - direct_tool = tool.get("direct", False) + tool_type = tool.get('type', '') + direct_tool = tool.get('direct', False) - spec = tool.get("spec", {}) - allowed_params = ( - spec.get("parameters", {}).get("properties", {}).keys() - ) - tool_function_params = { - k: v - for k, v in tool_function_params.items() - if k in allowed_params - } + spec = tool.get('spec', {}) + allowed_params = spec.get('parameters', {}).get('properties', {}).keys() + tool_function_params = {k: v for k, v in tool_function_params.items() if k in allowed_params} - if tool.get("direct", False): + if tool.get('direct', False): 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), + '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 = tool["callable"] + tool_function = tool['callable'] tool_result = await tool_function(**tool_function_params) except Exception as e: tool_result = str(e) - tool_result, tool_result_files, tool_result_embeds = ( - process_tool_result( - request, - tool_function_name, - tool_result, - tool_type, - direct_tool, - metadata, - user, - ) + tool_result, tool_result_files, tool_result_embeds = process_tool_result( + request, + tool_function_name, + tool_result, + tool_type, + direct_tool, + metadata, + user, ) if event_emitter: @@ -1330,9 +1325,9 @@ async def tool_call_handler(tool_call): if tool_result_files: await event_emitter( { - "type": "files", - "data": { - "files": tool_result_files, + 'type': 'files', + 'data': { + 'files': tool_result_files, }, } ) @@ -1340,79 +1335,69 @@ async def tool_call_handler(tool_call): if tool_result_embeds: await event_emitter( { - "type": "embeds", - "data": { - "embeds": tool_result_embeds, + 'type': 'embeds', + 'data': { + 'embeds': tool_result_embeds, }, } ) if tool_result: tool = tools[tool_function_name] - tool_id = tool.get("tool_id", "") + tool_id = tool.get('tool_id', '') - tool_name = ( - f"{tool_id}/{tool_function_name}" - if tool_id - else f"{tool_function_name}" - ) + tool_name = f'{tool_id}/{tool_function_name}' if tool_id else f'{tool_function_name}' # Citation is enabled for this tool sources.append( { - "source": { - "name": (f"{tool_name}"), + 'source': { + 'name': (f'{tool_name}'), }, - "document": [str(tool_result)], - "metadata": [ + 'document': [str(tool_result)], + 'metadata': [ { - "source": (f"{tool_name}"), - "parameters": tool_function_params, + 'source': (f'{tool_name}'), + 'parameters': tool_function_params, } ], - "tool_result": True, + 'tool_result': True, } ) - if ( - tools[tool_function_name] - .get("metadata", {}) - .get("file_handler", False) - ): + if tools[tool_function_name].get('metadata', {}).get('file_handler', False): skip_files = True # check if "tool_calls" in result - if result.get("tool_calls"): - for tool_call in result.get("tool_calls"): + if result.get('tool_calls'): + for tool_call in result.get('tool_calls'): await tool_call_handler(tool_call) else: await tool_call_handler(result) except Exception as e: - log.debug(f"Error: {e}") + log.debug(f'Error: {e}') content = None except Exception as e: - log.debug(f"Error: {e}") + log.debug(f'Error: {e}') content = None - log.debug(f"tool_contexts: {sources}") + log.debug(f'tool_contexts: {sources}') - if skip_files and "files" in body.get("metadata", {}): - del body["metadata"]["files"] + if skip_files and 'files' in body.get('metadata', {}): + del body['metadata']['files'] - return body, {"sources": sources} + return body, {'sources': sources} -async def chat_memory_handler( - request: Request, form_data: dict, extra_params: dict, user -): +async def chat_memory_handler(request: Request, form_data: dict, extra_params: dict, user): try: results = await query_memory( request, QueryMemoryForm( **{ - "content": get_last_user_message(form_data["messages"]) or "", - "k": 3, + 'content': get_last_user_message(form_data['messages']) or '', + 'k': 3, } ), user, @@ -1421,43 +1406,39 @@ async def chat_memory_handler( log.debug(e) results = None - user_context = "" - if results and hasattr(results, "documents"): + user_context = '' + if results and hasattr(results, 'documents'): if results.documents and len(results.documents) > 0: for doc_idx, doc in enumerate(results.documents[0]): - created_at_date = "Unknown Date" + created_at_date = 'Unknown Date' - if results.metadatas[0][doc_idx].get("created_at"): - created_at_timestamp = results.metadatas[0][doc_idx]["created_at"] - created_at_date = time.strftime( - "%Y-%m-%d", time.localtime(created_at_timestamp) - ) + if results.metadatas[0][doc_idx].get('created_at'): + created_at_timestamp = results.metadatas[0][doc_idx]['created_at'] + created_at_date = time.strftime('%Y-%m-%d', time.localtime(created_at_timestamp)) - user_context += f"{doc_idx + 1}. [{created_at_date}] {doc}\n" + user_context += f'{doc_idx + 1}. [{created_at_date}] {doc}\n' - form_data["messages"] = add_or_update_system_message( - f"User Context:\n{user_context}\n", form_data["messages"], append=True + form_data['messages'] = add_or_update_system_message( + f'User Context:\n{user_context}\n', form_data['messages'], append=True ) return form_data -async def chat_web_search_handler( - request: Request, form_data: dict, extra_params: dict, user -): - event_emitter = extra_params["__event_emitter__"] +async def chat_web_search_handler(request: Request, form_data: dict, extra_params: dict, user): + event_emitter = extra_params['__event_emitter__'] await event_emitter( { - "type": "status", - "data": { - "action": "web_search", - "description": "Searching the web", - "done": False, + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'Searching the web', + 'done': False, }, } ) - messages = form_data["messages"] + messages = form_data['messages'] user_message = get_last_user_message(messages) queries = [] @@ -1465,27 +1446,27 @@ async def chat_web_search_handler( res = await generate_queries( request, { - "model": form_data["model"], - "messages": messages, - "prompt": user_message, - "type": "web_search", - "chat_id": extra_params.get("__chat_id__"), + 'model': form_data['model'], + 'messages': messages, + 'prompt': user_message, + 'type': 'web_search', + 'chat_id': extra_params.get('__chat_id__'), }, user, ) - response = res["choices"][0]["message"]["content"] + response = res['choices'][0]['message']['content'] try: - bracket_start = response.find("{") - bracket_end = response.rfind("}") + 1 + bracket_start = response.rfind('{') + bracket_end = response.rfind('}') + 1 if bracket_start == -1 or bracket_end == -1: - raise Exception("No JSON object found in the response") + raise Exception('No JSON object found in the response') response = response[bracket_start:bracket_end] queries = json.loads(response) - queries = queries.get("queries", []) + queries = queries.get('queries', []) except Exception as e: queries = [response] @@ -1497,18 +1478,18 @@ async def chat_web_search_handler( queries = [user_message] # Check if generated queries are empty - if len(queries) == 1 and queries[0].strip() == "": + if len(queries) == 1 and queries[0].strip() == '': queries = [user_message] # Check if queries are not found if len(queries) == 0: await event_emitter( { - "type": "status", - "data": { - "action": "web_search", - "description": "No search query generated", - "done": True, + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'No search query generated', + 'done': True, }, } ) @@ -1516,11 +1497,11 @@ async def chat_web_search_handler( await event_emitter( { - "type": "status", - "data": { - "action": "web_search_queries_generated", - "queries": queries, - "done": False, + 'type': 'status', + 'data': { + 'action': 'web_search_queries_generated', + 'queries': queries, + 'done': False, }, } ) @@ -1533,57 +1514,55 @@ async def chat_web_search_handler( ) if results: - files = form_data.get("files", []) + files = form_data.get('files', []) - if results.get("collection_names"): - for col_idx, collection_name in enumerate( - results.get("collection_names") - ): + if results.get('collection_names'): + for col_idx, collection_name in enumerate(results.get('collection_names')): files.append( { - "collection_name": collection_name, - "name": ", ".join(queries), - "type": "web_search", - "urls": results["filenames"], - "queries": queries, + 'collection_name': collection_name, + 'name': ', '.join(queries), + 'type': 'web_search', + 'urls': results['filenames'], + 'queries': queries, } ) - elif results.get("docs"): + elif results.get('docs'): # Invoked when bypass embedding and retrieval is set to True - docs = results["docs"] + docs = results['docs'] files.append( { - "docs": docs, - "name": ", ".join(queries), - "type": "web_search", - "urls": results["filenames"], - "queries": queries, + 'docs': docs, + 'name': ', '.join(queries), + 'type': 'web_search', + 'urls': results['filenames'], + 'queries': queries, } ) - form_data["files"] = files + form_data['files'] = files await event_emitter( { - "type": "status", - "data": { - "action": "web_search", - "description": "Searched {{count}} sites", - "urls": results["filenames"], - "items": results.get("items", []), - "done": True, + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'Searched {{count}} sites', + 'urls': results['filenames'], + 'items': results.get('items', []), + 'done': True, }, } ) else: await event_emitter( { - "type": "status", - "data": { - "action": "web_search", - "description": "No search results found", - "done": True, - "error": True, + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'No search results found', + 'done': True, + 'error': True, }, } ) @@ -1592,13 +1571,13 @@ async def chat_web_search_handler( log.exception(e) await event_emitter( { - "type": "status", - "data": { - "action": "web_search", - "description": "An error occurred while searching the web", - "queries": queries, - "done": True, - "error": True, + 'type': 'status', + 'data': { + 'action': 'web_search', + 'description': 'An error occurred while searching the web', + 'queries': queries, + 'done': True, + 'error': True, }, } ) @@ -1610,13 +1589,12 @@ def get_images_from_messages(message_list): images = [] for message in reversed(message_list): - message_images = [] - for file in message.get("files", []): - if file.get("type") == "image": - message_images.append(file.get("url")) - elif file.get("content_type", "").startswith("image/"): - message_images.append(file.get("url")) + for file in message.get('files', []): + if file.get('type') == 'image': + message_images.append(file.get('url')) + elif file.get('content_type', '').startswith('image/'): + message_images.append(file.get('url')) if message_images: images.append(message_images) @@ -1630,14 +1608,14 @@ def get_image_urls(delta_images, request, metadata, user) -> list[str]: image_urls = [] for img in delta_images: - if not isinstance(img, dict) or img.get("type") != "image_url": + if not isinstance(img, dict) or img.get('type') != 'image_url': continue - url = img.get("image_url", {}).get("url") + url = img.get('image_url', {}).get('url') if not url: continue - if url.startswith("data:image/png;base64"): + if url.startswith('data:image/png;base64'): url = get_image_url_from_base64(request, url, metadata, user) image_urls.append(url) @@ -1649,72 +1627,75 @@ 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:"): + if not chat_id or chat_id.startswith('local:'): return messages chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) if not chat: return messages - history = chat.chat.get("history", {}) - stored_messages = get_message_list( - history.get("messages", {}), history.get("currentId") - ) + history = chat.chat.get('history', {}) + 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"]}"' - if file.get("content_type"): + if file.get('content_type'): attrs += f' content_type="{file["content_type"]}"' - if file.get("name"): + if file.get('name'): attrs += f' name="{file["name"]}"' - return f"" - - for message, stored_message in zip(messages, stored_messages): + return f'' + + # Pair only user-role messages from both lists to avoid misalignment. + # After process_messages_with_output(), assistant messages with tool calls + # are expanded into multiple messages (assistant + tool results), making + # the payload message list longer than the stored message list. A naive + # positional zip() would pair user messages with wrong stored messages, + # causing later images to lose their file context (see #21878). + user_messages = [m for m in messages if m.get('role') == 'user'] + stored_user_messages = [m for m in stored_messages if m.get('role') == 'user'] + + for message, stored_message in zip(user_messages, stored_user_messages): files_with_urls = [ file - for file in stored_message.get("files", []) - if file.get("url") and not file.get("url").startswith("data:") + for file in stored_message.get('files', []) + if file.get('url') and not file.get('url').startswith('data:') ] if not files_with_urls: continue file_tags = [format_file_tag(file) for file in files_with_urls] - file_context = ( - "\n" + "\n".join(file_tags) + "\n\n\n" - ) + file_context = '\n' + '\n'.join(file_tags) + '\n\n\n' - content = message.get("content", "") + content = message.get('content', '') if isinstance(content, list): - message["content"] = [{"type": "text", "text": file_context}] + content + message['content'] = [{'type': 'text', 'text': file_context}] + content else: - message["content"] = file_context + content + message['content'] = file_context + content return messages -async def chat_image_generation_handler( - request: Request, form_data: dict, extra_params: dict, user -): - metadata = extra_params.get("__metadata__", {}) - chat_id = metadata.get("chat_id", None) - __event_emitter__ = extra_params.get("__event_emitter__", None) +async def chat_image_generation_handler(request: Request, form_data: dict, extra_params: dict, user): + metadata = extra_params.get('__metadata__', {}) + chat_id = metadata.get('chat_id', None) + __event_emitter__ = extra_params.get('__event_emitter__', None) if not chat_id or not isinstance(chat_id, str) or not __event_emitter__: return form_data - if chat_id.startswith("local:"): - message_list = form_data.get("messages", []) + if chat_id.startswith('local:'): + message_list = form_data.get('messages', []) else: chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) await __event_emitter__( { - "type": "status", - "data": {"description": "Creating image", "done": False}, + 'type': 'status', + 'data': {'description': 'Creating image', 'done': False}, } ) - messages_map = chat.chat.get("history", {}).get("messages", {}) - message_id = chat.chat.get("history", {}).get("currentId") + messages_map = chat.chat.get('history', {}).get('messages', {}) + message_id = chat.chat.get('history', {}).get('currentId') message_list = get_message_list(messages_map, message_id) user_message = get_last_user_message(message_list) @@ -1731,36 +1712,36 @@ async def chat_image_generation_handler( for image in images: input_images.append(image) - system_message_content = "" + system_message_content = '' if len(input_images) > 0 and request.app.state.config.ENABLE_IMAGE_EDIT: # Edit image(s) try: images = await image_edits( request=request, - form_data=EditImageForm(**{"prompt": prompt, "image": input_images}), + form_data=EditImageForm(**{'prompt': prompt, 'image': input_images}), metadata={ - "chat_id": metadata.get("chat_id", None), - "message_id": metadata.get("message_id", None), + 'chat_id': metadata.get('chat_id', None), + 'message_id': metadata.get('message_id', None), }, user=user, ) await __event_emitter__( { - "type": "status", - "data": {"description": "Image created", "done": True}, + 'type': 'status', + 'data': {'description': 'Image created', 'done': True}, } ) await __event_emitter__( { - "type": "files", - "data": { - "files": [ + 'type': 'files', + 'data': { + 'files': [ { - "type": "image", - "url": image["url"], + 'type': 'image', + 'url': image['url'], } for image in images ] @@ -1768,28 +1749,28 @@ async def chat_image_generation_handler( } ) - system_message_content = "The requested image has been edited and created and is now being shown to the user. Let them know that it has been generated." + system_message_content = 'The requested image has been edited and created and is now being shown to the user. Let them know that it has been generated.' except Exception as e: log.debug(e) - error_message = "" + error_message = '' if isinstance(e, HTTPException): if e.detail and isinstance(e.detail, dict): - error_message = e.detail.get("message", str(e.detail)) + error_message = e.detail.get('message', str(e.detail)) else: error_message = str(e.detail) await __event_emitter__( { - "type": "status", - "data": { - "description": f"An error occurred while generating an image", - "done": True, + 'type': 'status', + 'data': { + 'description': f'An error occurred while generating an image', + 'done': True, }, } ) - system_message_content = f"Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}" + system_message_content = f'Image generation was attempted but failed. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}' else: # Create image(s) @@ -1798,25 +1779,25 @@ async def chat_image_generation_handler( res = await generate_image_prompt( request, { - "model": form_data["model"], - "messages": form_data["messages"], - "chat_id": metadata.get("chat_id"), + 'model': form_data['model'], + 'messages': form_data['messages'], + 'chat_id': metadata.get('chat_id'), }, user, ) - response = res["choices"][0]["message"]["content"] + response = res['choices'][0]['message']['content'] try: - bracket_start = response.find("{") - bracket_end = response.rfind("}") + 1 + bracket_start = response.rfind('{') + bracket_end = response.rfind('}') + 1 if bracket_start == -1 or bracket_end == -1: - raise Exception("No JSON object found in the response") + raise Exception('No JSON object found in the response') response = response[bracket_start:bracket_end] response = json.loads(response) - prompt = response.get("prompt", []) + prompt = response.get('prompt', []) except Exception as e: prompt = user_message @@ -1827,29 +1808,29 @@ async def chat_image_generation_handler( try: images = await image_generations( request=request, - form_data=CreateImageForm(**{"prompt": prompt}), + form_data=CreateImageForm(**{'prompt': prompt}), metadata={ - "chat_id": metadata.get("chat_id", None), - "message_id": metadata.get("message_id", None), + 'chat_id': metadata.get('chat_id', None), + 'message_id': metadata.get('message_id', None), }, user=user, ) await __event_emitter__( { - "type": "status", - "data": {"description": "Image created", "done": True}, + 'type': 'status', + 'data': {'description': 'Image created', 'done': True}, } ) await __event_emitter__( { - "type": "files", - "data": { - "files": [ + 'type': 'files', + 'data': { + 'files': [ { - "type": "image", - "url": image["url"], + 'type': 'image', + 'url': image['url'], } for image in images ] @@ -1857,33 +1838,31 @@ async def chat_image_generation_handler( } ) - system_message_content = "The requested image has been created by the system successfully and is now being shown to the user. Let the user know that the image they requested has been generated and is now shown in the chat." + system_message_content = 'The requested image has been created by the system successfully and is now being shown to the user. Let the user know that the image they requested has been generated and is now shown in the chat.' except Exception as e: log.debug(e) - error_message = "" + error_message = '' if isinstance(e, HTTPException): if e.detail and isinstance(e.detail, dict): - error_message = e.detail.get("message", str(e.detail)) + error_message = e.detail.get('message', str(e.detail)) else: error_message = str(e.detail) await __event_emitter__( { - "type": "status", - "data": { - "description": f"An error occurred while generating an image", - "done": True, + 'type': 'status', + 'data': { + 'description': f'An error occurred while generating an image', + 'done': True, }, } ) - system_message_content = f"Image generation was attempted but failed because of an error. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}" + system_message_content = f'Image generation was attempted but failed because of an error. The system is currently unable to generate the image. Tell the user that the following error occurred: {error_message}' if system_message_content: - form_data["messages"] = add_or_update_system_message( - system_message_content, form_data["messages"] - ) + form_data['messages'] = add_or_update_system_message(system_message_content, form_data['messages']) return form_data @@ -1891,12 +1870,12 @@ async def chat_image_generation_handler( async def chat_completion_files_handler( request: Request, body: dict, extra_params: dict, user: UserModel ) -> tuple[dict, dict[str, list]]: - __event_emitter__ = extra_params["__event_emitter__"] + __event_emitter__ = extra_params['__event_emitter__'] sources = [] - if files := body.get("metadata", {}).get("files", None): + if files := body.get('metadata', {}).get('files', None): # Check if all files are in full context mode - all_full_context = all(item.get("context") == "full" for item in files) + all_full_context = all(item.get('context') == 'full' for item in files) queries = [] if not all_full_context: @@ -1904,44 +1883,44 @@ async def chat_completion_files_handler( queries_response = await generate_queries( request, { - "model": body["model"], - "messages": body["messages"], - "type": "retrieval", - "chat_id": body.get("metadata", {}).get("chat_id"), + 'model': body['model'], + 'messages': body['messages'], + 'type': 'retrieval', + 'chat_id': body.get('metadata', {}).get('chat_id'), }, user, ) - queries_response = queries_response["choices"][0]["message"]["content"] + queries_response = queries_response['choices'][0]['message']['content'] try: - bracket_start = queries_response.find("{") - bracket_end = queries_response.rfind("}") + 1 + bracket_start = queries_response.rfind('{') + bracket_end = queries_response.rfind('}') + 1 if bracket_start == -1 or bracket_end == -1: - raise Exception("No JSON object found in the response") + raise Exception('No JSON object found in the response') queries_response = queries_response[bracket_start:bracket_end] queries_response = json.loads(queries_response) except Exception as e: - queries_response = {"queries": [queries_response]} + queries_response = {'queries': [queries_response]} - queries = queries_response.get("queries", []) - except: + queries = queries_response.get('queries', []) + except Exception: pass await __event_emitter__( { - "type": "status", - "data": { - "action": "queries_generated", - "queries": queries, - "done": False, + 'type': 'status', + 'data': { + 'action': 'queries_generated', + 'queries': queries, + 'done': False, }, } ) if len(queries) == 0: - queries = [get_last_user_message(body["messages"])] + queries = [get_last_user_message(body['messages'])] try: # Directly await async get_sources_from_items (no thread needed - fully async now) @@ -1954,11 +1933,7 @@ async def chat_completion_files_handler( ), k=request.app.state.config.TOP_K, reranking_function=( - ( - lambda query, documents: request.app.state.RERANKING_FUNCTION( - query, documents, user=user - ) - ) + (lambda query, documents: request.app.state.RERANKING_FUNCTION(query, documents, user=user)) if request.app.state.RERANKING_FUNCTION else None ), @@ -1966,58 +1941,53 @@ async def chat_completion_files_handler( r=request.app.state.config.RELEVANCE_THRESHOLD, hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT, hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH, - full_context=all_full_context - or request.app.state.config.RAG_FULL_CONTEXT, + full_context=all_full_context or request.app.state.config.RAG_FULL_CONTEXT, user=user, ) except Exception as e: log.exception(e) - log.debug(f"rag_contexts:sources: {sources}") + log.debug(f'rag_contexts:sources: {sources}') unique_ids = set() for source in sources or []: if not source or len(source.keys()) == 0: continue - documents = source.get("document") or [] - metadatas = source.get("metadata") or [] - src_info = source.get("source") or {} + documents = source.get('document') or [] + metadatas = source.get('metadata') or [] + src_info = source.get('source') or {} for index, _ in enumerate(documents): metadata = metadatas[index] if index < len(metadatas) else None - _id = ( - (metadata or {}).get("source") - or (src_info or {}).get("id") - or "N/A" - ) + _id = (metadata or {}).get('source') or (src_info or {}).get('id') or 'N/A' unique_ids.add(_id) sources_count = len(unique_ids) await __event_emitter__( { - "type": "status", - "data": { - "action": "sources_retrieved", - "count": sources_count, - "done": True, + 'type': 'status', + 'data': { + 'action': 'sources_retrieved', + 'count': sources_count, + 'done': True, }, } ) - return body, {"sources": sources} + return body, {'sources': sources} def apply_params_to_form_data(form_data, model): - params = form_data.pop("params", {}) - custom_params = params.pop("custom_params", {}) + params = form_data.pop('params', {}) + custom_params = params.pop('custom_params', {}) open_webui_params = { - "stream_response": bool, - "stream_delta_chunk_size": int, - "function_calling": str, - "reasoning_tags": list, - "system": str, + 'stream_response': bool, + 'stream_delta_chunk_size': int, + 'function_calling': str, + 'reasoning_tags': list, + 'system': str, } for key in list(params.keys()): @@ -2038,62 +2008,60 @@ def apply_params_to_form_data(form_data, model): # If custom_params are provided, merge them into params params = deep_update(params, custom_params) - if model.get("owned_by") == "ollama": + if model.get('owned_by') == 'ollama': # Ollama specific parameters - form_data["options"] = params + form_data['options'] = params else: if isinstance(params, dict): for key, value in params.items(): if value is not None: form_data[key] = value - if "logit_bias" in params and params["logit_bias"] is not None: + if 'logit_bias' in params and params['logit_bias'] is not None: try: - logit_bias = convert_logit_bias_input_to_json(params["logit_bias"]) + logit_bias = convert_logit_bias_input_to_json(params['logit_bias']) if logit_bias: - form_data["logit_bias"] = json.loads(logit_bias) + form_data['logit_bias'] = json.loads(logit_bias) except Exception as e: - log.exception(f"Error parsing logit_bias: {e}") + log.exception(f'Error parsing logit_bias: {e}') return form_data async def convert_url_images_to_base64(form_data): - messages = form_data.get("messages", []) + messages = form_data.get('messages', []) for message in messages: - content = message.get("content") + content = message.get('content') if not isinstance(content, list): continue new_content = [] for item in content: - if not isinstance(item, dict) or item.get("type") != "image_url": + if not isinstance(item, dict) or item.get('type') != 'image_url': new_content.append(item) continue - image_url = item.get("image_url", {}).get("url", "") - if image_url.startswith("data:image/"): + image_url = item.get('image_url', {}).get('url', '') + if image_url.startswith('data:image/'): new_content.append(item) continue try: - base64_data = await asyncio.to_thread( - get_image_base64_from_url, image_url - ) + base64_data = await asyncio.to_thread(get_image_base64_from_url, image_url) new_content.append( { - "type": "image_url", - "image_url": {"url": base64_data}, + 'type': 'image_url', + 'image_url': {'url': base64_data}, } ) except Exception as e: - log.debug(f"Error converting image URL to base64: {e}") + log.debug(f'Error converting image URL to base64: {e}') new_content.append(item) - message["content"] = new_content + message['content'] = new_content return form_data @@ -2111,10 +2079,7 @@ def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[dict]] if not db_messages: return None - return [ - {k: v for k, v in msg.items() if k in ("role", "content", "output", "files")} - for msg in db_messages - ] + return [{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files')} for msg in db_messages] def process_messages_with_output(messages: list[dict]) -> list[dict]: @@ -2127,15 +2092,15 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]: processed = [] for message in messages: - if message.get("role") == "assistant" and message.get("output"): + if message.get('role') == 'assistant' and message.get('output'): # Use output items for clean OpenAI-format messages - output_messages = convert_output_to_messages(message["output"], raw=True) + output_messages = convert_output_to_messages(message['output'], raw=True) if output_messages: processed.extend(output_messages) continue # Strip 'output' field before adding (LLM shouldn't see it) - clean_message = {k: v for k, v in message.items() if k != "output"} + clean_message = {k: v for k, v in message.items() if k != 'output'} processed.append(clean_message) return processed @@ -2146,57 +2111,83 @@ async def process_chat_payload(request, form_data, user, metadata, model): # -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling # -> Chat Files + # Arena model resolution โ€” pick the sub-model now so all downstream + # processing (knowledge, capabilities, tools, params) uses its settings + # instead of the empty arena wrapper. + if model.get('owned_by') == 'arena': + arena_model_ids = model.get('info', {}).get('meta', {}).get('model_ids') + arena_filter_mode = model.get('info', {}).get('meta', {}).get('filter_mode') + if arena_model_ids and arena_filter_mode == 'exclude': + arena_model_ids = [ + available_model['id'] + for available_model in request.app.state.MODELS.values() + if available_model.get('owned_by') != 'arena' and available_model['id'] not in arena_model_ids + ] + + if isinstance(arena_model_ids, list) and arena_model_ids: + selected_model_id = random.choice(arena_model_ids) + else: + arena_model_ids = [ + available_model['id'] + for available_model in request.app.state.MODELS.values() + if available_model.get('owned_by') != 'arena' + ] + selected_model_id = random.choice(arena_model_ids) + + selected_model = request.app.state.MODELS.get(selected_model_id) + if selected_model: + model = selected_model + form_data['model'] = selected_model_id + metadata['selected_model_id'] = selected_model_id + form_data = apply_params_to_form_data(form_data, model) - log.debug(f"form_data: {form_data}") + log.debug(f'form_data: {form_data}') # Load messages from DB when available โ€” DB preserves structured 'output' items # which the frontend strips, causing tool calls to be merged into content. - chat_id = metadata.get("chat_id") - parent_message_id = metadata.get("parent_message_id") + chat_id = metadata.get('chat_id') + parent_message_id = metadata.get('parent_message_id') - if chat_id and parent_message_id and not chat_id.startswith("local:"): + if chat_id and parent_message_id and not chat_id.startswith('local:'): db_messages = load_messages_from_db(chat_id, parent_message_id) if db_messages: - system_message = get_system_message(form_data.get("messages", [])) - form_data["messages"] = ( - [system_message, *db_messages] if system_message else db_messages - ) + system_message = get_system_message(form_data.get('messages', [])) + form_data['messages'] = [system_message, *db_messages] if system_message else db_messages # Inject image files into content as image_url parts (mirrors frontend logic) - for message in form_data["messages"]: + for message in form_data['messages']: image_files = [ f - for f in message.get("files", []) - if f.get("type") == "image" - or (f.get("content_type") or "").startswith("image/") + for f in message.get('files', []) + if f.get('type') == 'image' or (f.get('content_type') or '').startswith('image/') ] - if message.get("role") == "user" and image_files: - text_content = message.get("content", "") + if message.get('role') == 'user' and image_files: + text_content = message.get('content', '') if isinstance(text_content, str): - message["content"] = [ - {"type": "text", "text": text_content}, + message['content'] = [ + {'type': 'text', 'text': text_content}, *[ { - "type": "image_url", - "image_url": {"url": f["url"]}, + 'type': 'image_url', + 'image_url': {'url': f['url']}, } for f in image_files - if f.get("url") + if f.get('url') ], ] # Strip files field โ€” it's been incorporated into content - message.pop("files", None) + message.pop('files', None) # Process messages with OR-aligned output items for clean LLM messages - form_data["messages"] = process_messages_with_output(form_data.get("messages", [])) + form_data['messages'] = process_messages_with_output(form_data.get('messages', [])) - system_message = get_system_message(form_data.get("messages", [])) + system_message = get_system_message(form_data.get('messages', [])) if system_message: # Chat Controls/User Settings try: form_data = apply_system_prompt_to_body( - system_message.get("content"), form_data, metadata, user, replace=True + system_message.get('content'), form_data, metadata, user, replace=True ) # Required to handle system prompt variables - except: + except Exception: pass form_data = await convert_url_images_to_base64(form_data) @@ -2205,27 +2196,27 @@ async def process_chat_payload(request, form_data, user, metadata, model): event_caller = get_event_call(metadata) extra_params = { - "__event_emitter__": event_emitter, - "__event_call__": event_caller, - "__user__": user.model_dump() if isinstance(user, UserModel) else {}, - "__metadata__": metadata, - "__oauth_token__": await get_system_oauth_token(request, user), - "__request__": request, - "__model__": model, - "__chat_id__": metadata.get("chat_id"), - "__message_id__": metadata.get("message_id"), + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__oauth_token__': await get_system_oauth_token(request, user), + '__request__': request, + '__model__': model, + '__chat_id__': metadata.get('chat_id'), + '__message_id__': metadata.get('message_id'), } # Initialize events to store additional event to be sent to the client # Initialize contexts and citation - if getattr(request.state, "direct", False) and hasattr(request.state, "model"): + if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'): models = { - request.state.model["id"]: request.state.model, + request.state.model['id']: request.state.model, } else: models = request.app.state.MODELS task_model_id = get_task_model_id( - form_data["model"], + form_data['model'], request.app.state.config.TASK_MODEL, request.app.state.config.TASK_MODEL_EXTERNAL, models, @@ -2237,215 +2228,200 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Folder "Project" handling # Check if the request has chat_id and is inside of a folder # Uses lightweight column query โ€” only fetches folder_id, not the full chat JSON blob - chat_id = metadata.get("chat_id", None) + chat_id = metadata.get('chat_id', None) + folder_id = None if chat_id and user: folder_id = Chats.get_chat_folder_id(chat_id, user.id) - if folder_id: - folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id) - if folder and folder.data: - if "system_prompt" in folder.data: - form_data = apply_system_prompt_to_body( - folder.data["system_prompt"], form_data, metadata, user - ) - if "files" in folder.data: - if metadata.get("params", {}).get("function_calling") != "native": - form_data["files"] = [ - *folder.data["files"], - *form_data.get("files", []), - ] - else: - # Native FC: skip RAG injection, builtin tools - # will read folder knowledge from metadata. - metadata["folder_knowledge"] = folder.data["files"] + # Fallback: use folder_id from metadata (temporary chats have no DB record) + if not folder_id: + folder_id = metadata.get('folder_id', None) + + if folder_id and user: + folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id) + + if folder and folder.data: + if 'system_prompt' in folder.data: + form_data = apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user) + if 'files' in folder.data: + if metadata.get('params', {}).get('function_calling') != 'native': + form_data['files'] = [ + *folder.data['files'], + *form_data.get('files', []), + ] + else: + # Native FC: skip RAG injection, builtin tools + # will read folder knowledge from metadata. + metadata['folder_knowledge'] = folder.data['files'] # Model "Knowledge" handling - user_message = get_last_user_message(form_data["messages"]) - model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False) + user_message = get_last_user_message(form_data['messages']) + model_knowledge = model.get('info', {}).get('meta', {}).get('knowledge', False) - if ( - model_knowledge - and metadata.get("params", {}).get("function_calling") != "native" - ): + if model_knowledge and metadata.get('params', {}).get('function_calling') != 'native': await event_emitter( { - "type": "status", - "data": { - "action": "knowledge_search", - "query": user_message, - "done": False, + 'type': 'status', + 'data': { + 'action': 'knowledge_search', + 'query': user_message, + 'done': False, }, } ) knowledge_files = [] for item in model_knowledge: - if item.get("collection_name"): + if item.get('collection_name'): knowledge_files.append( { - "id": item.get("collection_name"), - "name": item.get("name"), - "legacy": True, + 'id': item.get('collection_name'), + 'name': item.get('name'), + 'legacy': True, } ) - elif item.get("collection_names"): + elif item.get('collection_names'): knowledge_files.append( { - "name": item.get("name"), - "type": "collection", - "collection_names": item.get("collection_names"), - "legacy": True, + 'name': item.get('name'), + 'type': 'collection', + 'collection_names': item.get('collection_names'), + 'legacy': True, } ) else: knowledge_files.append(item) - files = form_data.get("files", []) + files = form_data.get('files', []) files.extend(knowledge_files) - form_data["files"] = files + form_data['files'] = files - variables = form_data.pop("variables", None) + variables = form_data.pop('variables', None) # Process the form_data through the pipeline try: - form_data = await process_pipeline_inlet_filter( - request, form_data, user, models - ) + form_data = await process_pipeline_inlet_filter(request, form_data, user, models) except Exception as e: raise e try: - filter_ids = get_sorted_filter_ids( - request, model, metadata.get("filter_ids", []) - ) + filter_ids = get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) filter_functions = Functions.get_functions_by_ids(filter_ids) form_data, flags = await process_filter_functions( request=request, filter_functions=filter_functions, - filter_type="inlet", + filter_type='inlet', form_data=form_data, extra_params=extra_params, ) except Exception as e: - raise Exception(f"{e}") + raise Exception(f'{e}') - features = form_data.pop("features", None) or {} - extra_params["__features__"] = features + features = form_data.pop('features', None) or {} + extra_params['__features__'] = features if features: - if "voice" in features and features["voice"]: + if 'voice' in features and features['voice']: if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None: - if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != "": + if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != '': template = request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE else: template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE - form_data["messages"] = add_or_update_system_message( + form_data['messages'] = add_or_update_system_message( template, - form_data["messages"], + form_data['messages'], ) - if "memory" in features and features["memory"]: + if 'memory' in features and features['memory']: # Skip forced memory injection when native FC is enabled - model can use memory tools - if metadata.get("params", {}).get("function_calling") != "native": - form_data = await chat_memory_handler( - request, form_data, extra_params, user - ) + if metadata.get('params', {}).get('function_calling') != 'native': + form_data = await chat_memory_handler(request, form_data, extra_params, user) - if "web_search" in features and features["web_search"]: + if 'web_search' in features and features['web_search']: # Skip forced RAG web search when native FC is enabled - model can use web_search tool - if metadata.get("params", {}).get("function_calling") != "native": - form_data = await chat_web_search_handler( - request, form_data, extra_params, user - ) + if metadata.get('params', {}).get('function_calling') != 'native': + form_data = await chat_web_search_handler(request, form_data, extra_params, user) - if "image_generation" in features and features["image_generation"]: + if 'image_generation' in features and features['image_generation']: # Skip forced image generation when native FC is enabled - model can use generate_image tool - if metadata.get("params", {}).get("function_calling") != "native": - form_data = await chat_image_generation_handler( - request, form_data, extra_params, user - ) + if metadata.get('params', {}).get('function_calling') != 'native': + form_data = await chat_image_generation_handler(request, form_data, extra_params, user) - if "code_interpreter" in features and features["code_interpreter"]: - engine = getattr( - request.app.state.config, "CODE_INTERPRETER_ENGINE", "pyodide" - ) + if 'code_interpreter' in features and features['code_interpreter']: + engine = getattr(request.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide') # 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") != "native": + if metadata.get('params', {}).get('function_calling') != 'native': prompt = ( request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE - if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != "" + if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != '' else DEFAULT_CODE_INTERPRETER_PROMPT ) # Append filesystem awareness only for pyodide engine - if engine != "jupyter": + if engine != 'jupyter': prompt += CODE_INTERPRETER_PYODIDE_PROMPT - form_data["messages"] = add_or_update_user_message( + form_data['messages'] = add_or_update_user_message( prompt, - form_data["messages"], + form_data['messages'], ) else: # Native FC: tool docstring can't be dynamic, so inject # filesystem context into messages for pyodide engine - if engine != "jupyter": - form_data["messages"] = add_or_update_user_message( + if engine != 'jupyter': + form_data['messages'] = add_or_update_user_message( CODE_INTERPRETER_PYODIDE_PROMPT, - form_data["messages"], + form_data['messages'], ) - tool_ids = form_data.pop("tool_ids", None) - terminal_id = form_data.pop("terminal_id", None) - files = form_data.pop("files", None) + tool_ids = form_data.pop('tool_ids', None) + terminal_id = form_data.pop('terminal_id', None) + files = form_data.pop('files', None) # Caller-provided OpenAI-style tools take precedence over server-side # tool resolution (tool_ids, MCP servers, builtin tools). - payload_tools = form_data.get("tools", None) + payload_tools = form_data.get('tools', None) # Skills - user_skill_ids = set(form_data.pop("skill_ids", None) or []) - model_skill_ids = set(model.get("info", {}).get("meta", {}).get("skillIds", [])) + user_skill_ids = set(form_data.pop('skill_ids', None) or []) + model_skill_ids = set(model.get('info', {}).get('meta', {}).get('skillIds', [])) all_skill_ids = user_skill_ids | model_skill_ids available_skills = [] if all_skill_ids: from open_webui.models.skills import Skills as SkillsModel - accessible_skill_ids = { - s.id for s in SkillsModel.get_skills_by_user_id(user.id, "read") - } + accessible_skill_ids = {s.id for s in SkillsModel.get_skills_by_user_id(user.id, 'read')} available_skills = [ s for sid in all_skill_ids - if sid in accessible_skill_ids - and (s := SkillsModel.get_skill_by_id(sid)) - and s.is_active + if sid in accessible_skill_ids and (s := SkillsModel.get_skill_by_id(sid)) and s.is_active ] - skill_descriptions = "" + skill_descriptions = '' for skill in available_skills: if skill.id in user_skill_ids: # User-selected: inject full content - form_data["messages"] = add_or_update_system_message( + form_data['messages'] = add_or_update_system_message( f'\n{skill.content}\n', - form_data["messages"], + form_data['messages'], append=True, ) else: # Model-attached: name+description only - skill_descriptions += f"\n{skill.name}\n{skill.description or ''}\n\n" + skill_descriptions += f'\n{skill.name}\n{skill.description or ""}\n\n' if skill_descriptions: - form_data["messages"] = add_or_update_system_message( - f"\n{skill_descriptions}", - form_data["messages"], + form_data['messages'] = add_or_update_system_message( + f'\n{skill_descriptions}', + form_data['messages'], append=True, ) - prompt = get_last_user_message(form_data["messages"]) + prompt = get_last_user_message(form_data['messages']) # TODO: re-enable URL extraction from prompt # urls = [] # if prompt and len(prompt or "") < 500 and (not files or len(files) == 0): @@ -2456,14 +2432,14 @@ async def process_chat_payload(request, form_data, user, metadata, model): files = [] for file_item in files: - if file_item.get("type", "file") == "folder": + if file_item.get('type', 'file') == 'folder': # Get folder files - folder_id = file_item.get("id", None) + folder_id = file_item.get('id', None) if folder_id: folder = 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, *folder.data["files"]] + 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, *folder.data['files']] # files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]] # Remove duplicate files based on their content @@ -2471,23 +2447,23 @@ async def process_chat_payload(request, form_data, user, metadata, model): metadata = { **metadata, - "tool_ids": tool_ids, - "terminal_id": terminal_id, - "files": files, + 'tool_ids': tool_ids, + 'terminal_id': terminal_id, + 'files': files, } - form_data["metadata"] = metadata + form_data['metadata'] = metadata # When the caller provides an explicit OpenAI-style `tools` array in the # request body, skip all server-side tool resolution and pass the caller's # tools through to the model unchanged. if not payload_tools: # Server side tools - tool_ids = metadata.get("tool_ids", None) + tool_ids = metadata.get('tool_ids', None) # Client side tools - direct_tool_servers = metadata.get("tool_servers", None) + direct_tool_servers = metadata.get('tool_servers', None) - log.debug(f"{tool_ids=}") - log.debug(f"{direct_tool_servers=}") + log.debug(f'{tool_ids=}') + log.debug(f'{direct_tool_servers=}') tools_dict = {} @@ -2496,70 +2472,57 @@ async def process_chat_payload(request, form_data, user, metadata, model): if tool_ids: for tool_id in tool_ids: - if tool_id.startswith("server:mcp:"): + if tool_id.startswith('server:mcp:'): try: - server_id = tool_id[len("server:mcp:") :] + server_id = tool_id[len('server:mcp:') :] mcp_server_connection = None - for ( - server_connection - ) in request.app.state.config.TOOL_SERVER_CONNECTIONS: + for server_connection in request.app.state.config.TOOL_SERVER_CONNECTIONS: if ( - server_connection.get("type", "") == "mcp" - and server_connection.get("info", {}).get("id") - == server_id + server_connection.get('type', '') == 'mcp' + and server_connection.get('info', {}).get('id') == server_id ): mcp_server_connection = server_connection break if not mcp_server_connection: - log.error(f"MCP server with id {server_id} not found") + log.error(f'MCP server with id {server_id} not found') continue # Check access control for MCP server if not has_connection_access(user, mcp_server_connection): - log.warning( - f"Access denied to MCP server {server_id} for user {user.id}" - ) + log.warning(f'Access denied to MCP server {server_id} for user {user.id}') continue - auth_type = mcp_server_connection.get("auth_type", "") + auth_type = mcp_server_connection.get('auth_type', '') headers = {} - if auth_type == "bearer": - headers["Authorization"] = ( - f"Bearer {mcp_server_connection.get('key', '')}" - ) - elif auth_type == "none": + if auth_type == 'bearer': + headers['Authorization'] = f'Bearer {mcp_server_connection.get("key", "")}' + elif auth_type == 'none': # No authentication pass - elif auth_type == "session": - headers["Authorization"] = ( - f"Bearer {request.state.token.credentials}" - ) - elif auth_type == "system_oauth": - oauth_token = extra_params.get("__oauth_token__", None) + elif auth_type == 'session': + headers['Authorization'] = f'Bearer {request.state.token.credentials}' + elif auth_type == 'system_oauth': + oauth_token = extra_params.get('__oauth_token__', None) if oauth_token: - headers["Authorization"] = ( - f"Bearer {oauth_token.get('access_token', '')}" - ) - elif auth_type == "oauth_2.1": + headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' + elif auth_type == 'oauth_2.1': try: - splits = server_id.split(":") + splits = server_id.split(':') server_id = splits[-1] if len(splits) > 1 else server_id oauth_token = await request.app.state.oauth_client_manager.get_oauth_token( - user.id, f"mcp:{server_id}" + user.id, f'mcp:{server_id}' ) if oauth_token: - headers["Authorization"] = ( - f"Bearer {oauth_token.get('access_token', '')}" - ) + headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' except Exception as e: - log.error(f"Error getting OAuth token: {e}") + log.error(f'Error getting OAuth token: {e}') oauth_token = None - connection_headers = mcp_server_connection.get("headers", None) + connection_headers = mcp_server_connection.get('headers', None) if connection_headers and isinstance(connection_headers, dict): for key, value in connection_headers.items(): headers[key] = value @@ -2567,29 +2530,23 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Add user info headers if enabled if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) - if metadata and metadata.get("chat_id"): - headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = ( - metadata.get("chat_id") - ) - if metadata and metadata.get("message_id"): - headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = ( - metadata.get("message_id") - ) + if metadata and metadata.get('chat_id'): + headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') + if metadata and metadata.get('message_id'): + headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = metadata.get('message_id') mcp_clients[server_id] = MCPClient() await mcp_clients[server_id].connect( - url=mcp_server_connection.get("url", ""), + url=mcp_server_connection.get('url', ''), headers=headers if headers else None, ) - function_name_filter_list = mcp_server_connection.get( - "config", {} - ).get("function_name_filter_list", "") + function_name_filter_list = mcp_server_connection.get('config', {}).get( + 'function_name_filter_list', '' + ) if isinstance(function_name_filter_list, str): - function_name_filter_list = function_name_filter_list.split( - "," - ) + function_name_filter_list = function_name_filter_list.split(',') tool_specs = await mcp_clients[server_id].list_tool_specs() for tool_spec in tool_specs: @@ -2604,37 +2561,29 @@ async def tool_function(**kwargs): return tool_function if function_name_filter_list: - if not is_string_allowed( - tool_spec["name"], function_name_filter_list - ): + if not is_string_allowed(tool_spec['name'], function_name_filter_list): # Skip this function continue - tool_function = make_tool_function( - mcp_clients[server_id], tool_spec["name"] - ) + tool_function = make_tool_function(mcp_clients[server_id], tool_spec['name']) - mcp_tools_dict[f"{server_id}_{tool_spec['name']}"] = { - "spec": { + mcp_tools_dict[f'{server_id}_{tool_spec["name"]}'] = { + 'spec': { **tool_spec, - "name": f"{server_id}_{tool_spec['name']}", + 'name': f'{server_id}_{tool_spec["name"]}', }, - "callable": tool_function, - "type": "mcp", - "client": mcp_clients[server_id], - "direct": False, + 'callable': tool_function, + 'type': 'mcp', + 'client': mcp_clients[server_id], + 'direct': False, } except Exception as e: log.debug(e) if event_emitter: await event_emitter( { - "type": "chat:message:error", - "data": { - "error": { - "content": f"Failed to connect to MCP server '{server_id}'" - } - }, + 'type': 'chat:message:error', + 'data': {'error': {'content': f"Failed to connect to MCP server '{server_id}'"}}, } ) continue @@ -2645,9 +2594,9 @@ async def tool_function(**kwargs): user, { **extra_params, - "__model__": models[task_model_id], - "__messages__": form_data["messages"], - "__files__": metadata.get("files", []), + '__model__': models[task_model_id], + '__messages__': form_data['messages'], + '__files__': metadata.get('files', []), }, ) @@ -2658,7 +2607,7 @@ async def tool_function(**kwargs): # so system terminals work even when no other tools are selected) if terminal_id: try: - terminal_tools = await get_terminal_tools( + terminal_tools, system_prompt = await get_terminal_tools( request, terminal_id, user, @@ -2666,45 +2615,52 @@ async def tool_function(**kwargs): ) if terminal_tools: tools_dict = {**tools_dict, **terminal_tools} + if system_prompt: + form_data['messages'] = add_or_update_system_message( + system_prompt, + form_data['messages'], + append=True, + ) except Exception as e: log.exception(e) if direct_tool_servers: for tool_server in direct_tool_servers: - tool_specs = tool_server.pop("specs", []) + system_prompt = tool_server.pop('system_prompt', None) + if system_prompt: + form_data['messages'] = add_or_update_system_message( + system_prompt, + form_data['messages'], + append=True, + ) + + tool_specs = tool_server.pop('specs', []) for tool in tool_specs: - tools_dict[tool["name"]] = { - "spec": tool, - "direct": True, - "server": tool_server, + tools_dict[tool['name']] = { + 'spec': tool, + 'direct': True, + 'server': tool_server, } if mcp_clients: - metadata["mcp_clients"] = mcp_clients + metadata['mcp_clients'] = mcp_clients # Inject builtin tools for native function calling based on enabled features and model capability # Check if builtin_tools capability is enabled for this model (defaults to True if not specified) - builtin_tools_enabled = ( - model.get("info", {}).get("meta", {}).get("capabilities") or {} - ).get("builtin_tools", True) - if ( - metadata.get("params", {}).get("function_calling") == "native" - and builtin_tools_enabled - ): + builtin_tools_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get( + 'builtin_tools', True + ) + if metadata.get('params', {}).get('function_calling') == 'native' and builtin_tools_enabled: # Add file context to user messages - chat_id = metadata.get("chat_id") - form_data["messages"] = add_file_context( - form_data.get("messages", []), chat_id, user - ) + chat_id = metadata.get('chat_id') + form_data['messages'] = add_file_context(form_data.get('messages', []), chat_id, user) builtin_tools = get_builtin_tools( request, { **extra_params, - "__event_emitter__": event_emitter, - "__skill_ids__": [ - s.id for s in available_skills if s.id not in user_skill_ids - ], + '__event_emitter__': event_emitter, + '__skill_ids__': [s.id for s in available_skills if s.id not in user_skill_ids], }, features, model, @@ -2714,12 +2670,11 @@ async def tool_function(**kwargs): tools_dict[name] = tool_dict if tools_dict: - if metadata.get("params", {}).get("function_calling") == "native": + if metadata.get('params', {}).get('function_calling') == 'native': # If the function calling is native, then call the tools function calling handler - metadata["tools"] = tools_dict - form_data["tools"] = [ - {"type": "function", "function": tool.get("spec", {})} - for tool in tools_dict.values() + metadata['tools'] = tools_dict + form_data['tools'] = [ + {'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values() ] else: # If the function calling is not native, then call the tools function calling handler @@ -2727,64 +2682,63 @@ async def tool_function(**kwargs): form_data, flags = await chat_completion_tools_handler( request, form_data, extra_params, user, models, tools_dict ) - sources.extend(flags.get("sources", [])) + sources.extend(flags.get('sources', [])) except Exception as e: log.exception(e) # Check if file context extraction is enabled for this model (default True) - file_context_enabled = ( - model.get("info", {}).get("meta", {}).get("capabilities") or {} - ).get("file_context", True) + file_context_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('file_context', True) if file_context_enabled: try: - form_data, flags = await chat_completion_files_handler( - request, form_data, extra_params, user - ) - sources.extend(flags.get("sources", [])) + form_data, flags = await chat_completion_files_handler(request, form_data, extra_params, user) + sources.extend(flags.get('sources', [])) except Exception as e: log.exception(e) # Save the pre-RAG message state so the native tool call loop can # restore to the true original (before file-source injection) rather # than a snapshot that already has the RAG template baked in. - system_message = get_system_message(form_data["messages"]) - metadata["system_prompt"] = ( - get_content_from_message(system_message) if system_message else None - ) - metadata["user_prompt"] = get_last_user_message(form_data["messages"]) - metadata["sources"] = sources[:] if sources else [] + system_message = get_system_message(form_data['messages']) + metadata['system_prompt'] = get_content_from_message(system_message) if system_message else None + metadata['user_prompt'] = get_last_user_message(form_data['messages']) + metadata['sources'] = sources[:] if sources else [] # If context is not empty, insert it into the messages if sources and prompt: - form_data["messages"] = apply_source_context_to_messages( - request, form_data["messages"], sources, prompt - ) + form_data['messages'] = apply_source_context_to_messages(request, form_data['messages'], sources, prompt) # If there are citations, add them to the data_items sources = [ source for source in sources - if source.get("source", {}).get("name", "") - or source.get("source", {}).get("id", "") + if source.get('source', {}).get('name', '') or source.get('source', {}).get('id', '') ] if len(sources) > 0: - events.append({"sources": sources}) + events.append({'sources': sources}) if model_knowledge: await event_emitter( { - "type": "status", - "data": { - "action": "knowledge_search", - "query": user_message, - "done": True, - "hidden": True, + 'type': 'status', + 'data': { + 'action': 'knowledge_search', + 'query': user_message, + 'done': True, + 'hidden': True, }, } ) + # Strip empty text content blocks from multimodal messages + # to prevent errors from providers like Gemini and Claude + form_data['messages'] = strip_empty_content_blocks(form_data.get('messages', [])) + + # Merge any duplicate system messages into a single message at position 0 + # to prevent template parsing errors with strict chat templates (e.g. Qwen) + form_data['messages'] = merge_system_messages(form_data.get('messages', [])) + return form_data, metadata, events @@ -2792,32 +2746,30 @@ def get_event_emitter_and_caller(metadata): event_emitter = None event_caller = None if ( - "session_id" in metadata - and metadata["session_id"] - and "chat_id" in metadata - and metadata["chat_id"] - and "message_id" in metadata - and metadata["message_id"] + 'session_id' in metadata + and metadata['session_id'] + and 'chat_id' in metadata + and metadata['chat_id'] + and 'message_id' in metadata + and metadata['message_id'] ): event_emitter = get_event_emitter(metadata) event_caller = get_event_call(metadata) return event_emitter, event_caller -def build_chat_response_context( - request, form_data, user, model, metadata, tasks, events -): +def build_chat_response_context(request, form_data, user, model, metadata, tasks, events): event_emitter, event_caller = get_event_emitter_and_caller(metadata) return { - "request": request, - "form_data": form_data, - "user": user, - "model": model, - "metadata": metadata, - "tasks": tasks, - "events": events, - "event_emitter": event_emitter, - "event_caller": event_caller, + 'request': request, + 'form_data': form_data, + 'user': user, + 'model': model, + 'metadata': metadata, + 'tasks': tasks, + 'events': events, + 'event_emitter': event_emitter, + 'event_caller': event_caller, } @@ -2829,9 +2781,9 @@ def get_response_data(response): if isinstance(response, JSONResponse): if isinstance(response.body, bytes): try: - response_data = json.loads(response.body.decode("utf-8", "replace")) + response_data = json.loads(response.body.decode('utf-8', 'replace')) except json.JSONDecodeError: - response_data = {"error": {"detail": "Invalid JSON response"}} + response_data = {'error': {'detail': 'Invalid JSON response'}} else: response_data = response elif isinstance(response, dict): @@ -2873,32 +2825,32 @@ def build_response_object(response, response_data): async def get_system_oauth_token(request, user): oauth_token = None try: - if request.cookies.get("oauth_session_id", None): + if request.cookies.get('oauth_session_id', None): oauth_token = await request.app.state.oauth_manager.get_oauth_token( user.id, - request.cookies.get("oauth_session_id", None), + request.cookies.get('oauth_session_id', None), ) except Exception as e: - log.error(f"Error getting OAuth token: {e}") + log.error(f'Error getting OAuth token: {e}') return oauth_token async def background_tasks_handler(ctx): - request = ctx["request"] - form_data = ctx["form_data"] - user = ctx["user"] - metadata = ctx["metadata"] - tasks = ctx["tasks"] - event_emitter = ctx["event_emitter"] + request = ctx['request'] + form_data = ctx['form_data'] + user = ctx['user'] + metadata = ctx['metadata'] + tasks = ctx['tasks'] + event_emitter = ctx['event_emitter'] message = None messages = [] - if "chat_id" in metadata and not metadata["chat_id"].startswith("local:"): - messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"]) - message = messages_map.get(metadata["message_id"]) if messages_map else None + if 'chat_id' in metadata and not metadata['chat_id'].startswith('local:'): + messages_map = Chats.get_messages_map_by_chat_id(metadata['chat_id']) + message = messages_map.get(metadata['message_id']) if messages_map else None - message_list = get_message_list(messages_map, metadata["message_id"]) + message_list = get_message_list(messages_map, metadata['message_id']) # Remove details tags and files from the messages. # as get_message_list creates a new list, it does not affect @@ -2906,17 +2858,17 @@ async def background_tasks_handler(ctx): messages = [] for message in message_list: - content = message.get("content", "") + content = message.get('content', '') if isinstance(content, list): for item in content: - if item.get("type") == "text": - content = item["text"] + if item.get('type') == 'text': + content = item['text'] break if isinstance(content, str): content = re.sub( - r"]*>.*?<\/details>|!\[.*?\]\(.*?\)", - "", + r']*>.*?<\/details>|!\[.*?\]\(.*?\)', + '', content, flags=re.S | re.I, ).strip() @@ -2924,141 +2876,128 @@ async def background_tasks_handler(ctx): messages.append( { **message, - "role": message.get( - "role", "assistant" - ), # Safe fallback for missing role - "content": content, + 'role': message.get('role', 'assistant'), # Safe fallback for missing role + 'content': content, } ) else: # Local temp chat, get the model and message from the form_data - message = get_last_user_message_item(form_data.get("messages", [])) - messages = form_data.get("messages", []) + message = get_last_user_message_item(form_data.get('messages', [])) + messages = form_data.get('messages', []) if message: - message["model"] = form_data.get("model") + message['model'] = form_data.get('model') - if message and "model" in message: + if message and 'model' in message: if tasks and messages: - if ( - TASKS.FOLLOW_UP_GENERATION in tasks - and tasks[TASKS.FOLLOW_UP_GENERATION] - ): + if TASKS.FOLLOW_UP_GENERATION in tasks and tasks[TASKS.FOLLOW_UP_GENERATION]: res = await generate_follow_ups( request, { - "model": message["model"], - "messages": messages, - "message_id": metadata["message_id"], - "chat_id": metadata["chat_id"], + 'model': message['model'], + 'messages': messages, + 'message_id': metadata['message_id'], + 'chat_id': metadata['chat_id'], }, user, ) if res and isinstance(res, dict): - if len(res.get("choices", [])) == 1: - response_message = res.get("choices", [])[0].get("message", {}) + if len(res.get('choices', [])) == 1: + response_message = res.get('choices', [])[0].get('message', {}) - follow_ups_string = response_message.get( - "content" - ) or response_message.get("reasoning_content", "") + follow_ups_string = response_message.get('content') or response_message.get( + 'reasoning_content', '' + ) else: - follow_ups_string = "" + follow_ups_string = '' follow_ups_string = follow_ups_string[ - follow_ups_string.find("{") : follow_ups_string.rfind("}") + 1 + follow_ups_string.find('{') : follow_ups_string.rfind('}') + 1 ] try: - follow_ups = json.loads(follow_ups_string).get("follow_ups", []) + follow_ups = json.loads(follow_ups_string).get('follow_ups', []) await event_emitter( { - "type": "chat:message:follow_ups", - "data": { - "follow_ups": follow_ups, + 'type': 'chat:message:follow_ups', + 'data': { + 'follow_ups': follow_ups, }, } ) - if not metadata.get("chat_id", "").startswith("local:"): + if not metadata.get('chat_id', '').startswith('local:'): Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "followUps": follow_ups, + 'followUps': follow_ups, }, ) except Exception as e: pass - if not metadata.get("chat_id", "").startswith( - "local:" - ): # Only update titles and tags for non-temp chats + if not metadata.get('chat_id', '').startswith('local:'): # Only update titles and tags for non-temp chats if TASKS.TITLE_GENERATION in tasks: user_message = get_last_user_message(messages) if user_message and len(user_message) > 100: - user_message = user_message[:100] + "..." + user_message = user_message[:100] + '...' title = None if tasks[TASKS.TITLE_GENERATION]: res = await generate_title( request, { - "model": message["model"], - "messages": messages, - "chat_id": metadata["chat_id"], + 'model': message['model'], + 'messages': messages, + 'chat_id': metadata['chat_id'], }, user, ) if res and isinstance(res, dict): - if len(res.get("choices", [])) == 1: - response_message = res.get("choices", [])[0].get( - "message", {} - ) + if len(res.get('choices', [])) == 1: + response_message = res.get('choices', [])[0].get('message', {}) title_string = ( - response_message.get("content") + response_message.get('content') or response_message.get( - "reasoning_content", + 'reasoning_content', ) - or message.get("content", user_message) + or message.get('content', user_message) ) else: - title_string = "" + title_string = '' - title_string = title_string[ - title_string.find("{") : title_string.rfind("}") + 1 - ] + title_string = title_string[title_string.find('{') : title_string.rfind('}') + 1] try: - title = json.loads(title_string).get( - "title", user_message - ) + title = json.loads(title_string).get('title', user_message) except Exception as e: - title = "" + title = '' if not title: - title = messages[0].get("content", user_message) + title = messages[0].get('content', user_message) - Chats.update_chat_title_by_id(metadata["chat_id"], title) + Chats.update_chat_title_by_id(metadata['chat_id'], title) await event_emitter( { - "type": "chat:title", - "data": title, + 'type': 'chat:title', + 'data': title, } ) - if title == None and len(messages) == 2: - title = messages[0].get("content", user_message) + if title == None and len(messages) == 2 and (not messages_map or len(messages_map) <= 2): + title = messages[0].get('content', user_message) - Chats.update_chat_title_by_id(metadata["chat_id"], title) + Chats.update_chat_title_by_id(metadata['chat_id'], title) await event_emitter( { - "type": "chat:title", - "data": message.get("content", user_message), + 'type': 'chat:title', + 'data': message.get('content', user_message), } ) @@ -3066,39 +3005,33 @@ async def background_tasks_handler(ctx): res = await generate_chat_tags( request, { - "model": message["model"], - "messages": messages, - "chat_id": metadata["chat_id"], + 'model': message['model'], + 'messages': messages, + 'chat_id': metadata['chat_id'], }, user, ) if res and isinstance(res, dict): - if len(res.get("choices", [])) == 1: - response_message = res.get("choices", [])[0].get( - "message", {} - ) + if len(res.get('choices', [])) == 1: + response_message = res.get('choices', [])[0].get('message', {}) - tags_string = response_message.get( - "content" - ) or response_message.get("reasoning_content", "") + tags_string = response_message.get('content') or response_message.get( + 'reasoning_content', '' + ) else: - tags_string = "" + tags_string = '' - tags_string = tags_string[ - tags_string.find("{") : tags_string.rfind("}") + 1 - ] + tags_string = tags_string[tags_string.find('{') : tags_string.rfind('}') + 1] try: - tags = json.loads(tags_string).get("tags", []) - Chats.update_chat_tags_by_id( - metadata["chat_id"], tags, user - ) + tags = json.loads(tags_string).get('tags', []) + Chats.update_chat_tags_by_id(metadata['chat_id'], tags, user) await event_emitter( { - "type": "chat:tags", - "data": tags, + 'type': 'chat:tags', + 'data': tags, } ) except Exception as e: @@ -3106,13 +3039,13 @@ async def background_tasks_handler(ctx): async def non_streaming_chat_response_handler(response, ctx): - request = ctx["request"] + request = ctx['request'] - user = ctx["user"] - metadata = ctx["metadata"] - events = ctx["events"] + user = ctx['user'] + metadata = ctx['metadata'] + events = ctx['events'] - event_emitter = ctx["event_emitter"] + event_emitter = ctx['event_emitter'] response, response_data = get_response_data(response) if response_data is None: @@ -3120,115 +3053,114 @@ async def non_streaming_chat_response_handler(response, ctx): if event_emitter: try: - if "error" in response_data: - error = response_data.get("error") + if 'error' in response_data: + error = response_data.get('error') if isinstance(error, dict): - error = error.get("detail", error) + error = error.get('detail', error) else: error = str(error) Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "error": {"content": error}, + 'error': {'content': error}, }, ) if isinstance(error, str) or isinstance(error, dict): await event_emitter( { - "type": "chat:message:error", - "data": {"error": {"content": error}}, + 'type': 'chat:message:error', + 'data': {'error': {'content': error}}, } ) - if "selected_model_id" in response_data: + if 'selected_model_id' in response_data: Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "selectedModelId": response_data["selected_model_id"], + 'selectedModelId': response_data['selected_model_id'], }, ) - choices = response_data.get("choices", []) - if choices and choices[0].get("message", {}).get("content"): - content = response_data["choices"][0]["message"]["content"] + choices = response_data.get('choices', []) + if choices and choices[0].get('message', {}).get('content'): + content = response_data['choices'][0]['message']['content'] if content: await event_emitter( { - "type": "chat:completion", - "data": response_data, + 'type': 'chat:completion', + 'data': response_data, } ) - title = Chats.get_chat_title_by_id(metadata["chat_id"]) + title = Chats.get_chat_title_by_id(metadata['chat_id']) # Use output from backend if provided (OR-compliant backends), # otherwise generate from response content - response_output = response_data.get("output") + response_output = response_data.get('output') if not response_output: response_output = [ { - "type": "message", - "id": output_id("msg"), - "status": "completed", - "role": "assistant", - "content": [{"type": "output_text", "text": content}], + 'type': 'message', + 'id': output_id('msg'), + 'status': 'completed', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': content}], } ] await event_emitter( { - "type": "chat:completion", - "data": { - "done": True, - "content": content, - "output": response_output, - "title": title, + 'type': 'chat:completion', + 'data': { + 'done': True, + 'content': content, + 'output': response_output, + 'title': title, }, } ) # Save message in the database - usage = normalize_usage(response_data.get("usage", {}) or {}) + usage = normalize_usage(response_data.get('usage', {}) or {}) Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "role": "assistant", - "content": content, - "output": response_output, - **({"usage": usage} if usage else {}), + 'done': True, + 'role': 'assistant', + 'content': content, + 'output': response_output, + **({'usage': usage} if usage else {}), }, ) # Send a webhook notification if the user is not active - if not Users.is_user_active(user.id): + if request.app.state.config.ENABLE_USER_WEBHOOKS and not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( request.app.state.WEBUI_NAME, webhook_url, - f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}", + f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}', { - "action": "chat", - "message": content, - "title": title, - "url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}", + 'action': 'chat', + 'message': content, + 'title': title, + 'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', }, ) await background_tasks_handler(ctx) - response = build_response_object( - response, merge_events_into_response(response_data, events) - ) + 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}") + log.debug(f'Error occurred while processing request: {e}') pass return response @@ -3240,40 +3172,38 @@ async def non_streaming_chat_response_handler(response, ctx): async def streaming_chat_response_handler(response, ctx): - request = ctx["request"] + request = ctx['request'] - form_data = ctx["form_data"] + form_data = ctx['form_data'] - user = ctx["user"] - model = ctx["model"] + user = ctx['user'] + model = ctx['model'] - metadata = ctx["metadata"] - events = ctx["events"] + metadata = ctx['metadata'] + events = ctx['events'] - event_emitter = ctx["event_emitter"] - event_caller = ctx["event_caller"] + event_emitter = ctx['event_emitter'] + event_caller = ctx['event_caller'] extra_params = { - "__event_emitter__": event_emitter, - "__event_call__": event_caller, - "__user__": user.model_dump() if isinstance(user, UserModel) else {}, - "__metadata__": metadata, - "__oauth_token__": await get_system_oauth_token(request, user), - "__request__": request, - "__model__": model, + '__event_emitter__': event_emitter, + '__event_call__': event_caller, + '__user__': user.model_dump() if isinstance(user, UserModel) else {}, + '__metadata__': metadata, + '__oauth_token__': await get_system_oauth_token(request, user), + '__request__': request, + '__model__': model, } filter_functions = [ Functions.get_function_by_id(filter_id) - for filter_id in get_sorted_filter_ids( - request, model, metadata.get("filter_ids", []) - ) + for filter_id in get_sorted_filter_ids(request, model, metadata.get('filter_ids', [])) ] # Standard streaming response handler if event_emitter and event_caller: task_id = str(uuid4()) # Create a unique task ID. - model_id = form_data.get("model", "") + model_id = form_data.get('model', '') # Handle as a background task async def response_handler(response, events): @@ -3300,46 +3230,43 @@ def extract_attributes(tag_content): def get_last_text(out): """Get text from last message item, or empty string.""" - if out and out[-1].get("type") == "message": - parts = out[-1].get("content", []) - if parts and parts[-1].get("type") == "output_text": - return parts[-1].get("text", "") - return "" + if out and out[-1].get('type') == 'message': + parts = out[-1].get('content', []) + if parts and parts[-1].get('type') == 'output_text': + return parts[-1].get('text', '') + return '' def set_last_text(out, text): """Set text on last message item's output_text.""" - if out and out[-1].get("type") == "message": - parts = out[-1].get("content", []) - if parts and parts[-1].get("type") == "output_text": - parts[-1]["text"] = text + if out and out[-1].get('type') == 'message': + parts = out[-1].get('content', []) + if parts and parts[-1].get('type') == 'output_text': + parts[-1]['text'] = text # Map content_type to output item type output_type_map = { - "reasoning": "reasoning", - "solution": "message", # solution tags just produce text - "code_interpreter": "open_webui:code_interpreter", + 'reasoning': 'reasoning', + 'solution': 'message', # solution tags just produce text + 'code_interpreter': 'open_webui:code_interpreter', } output_item_type = output_type_map.get(content_type, content_type) - last_type = output[-1].get("type", "") if output else "" + last_type = output[-1].get('type', '') if output else '' - if last_type == "message": + if last_type == 'message': # Use the output item's own text for tag detection item_text = get_last_text(output) for start_tag, end_tag in tags: - - start_tag_pattern = rf"{re.escape(start_tag)}" - if start_tag.startswith("<") and start_tag.endswith(">"): - start_tag_pattern = ( - rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>" - ) + start_tag_pattern = rf'{re.escape(start_tag)}' + if start_tag.startswith('<') and start_tag.endswith('>'): + start_tag_pattern = rf'<{re.escape(start_tag[1:-1])}(\s.*?)?>' match = re.search(start_tag_pattern, item_text) if match: try: - attr_content = match.group(1) if match.group(1) else "" - except: - attr_content = "" + attr_content = match.group(1) if match.group(1) else '' + except Exception: + attr_content = '' attributes = extract_attributes(attr_content) @@ -3351,102 +3278,90 @@ def set_last_text(out, text): if not before_tag.strip(): # Remove empty message item - if output and output[-1].get("type") == "message": + if output and output[-1].get('type') == 'message': output.pop() # Append the new output item - if output_item_type == "reasoning": + if output_item_type == 'reasoning': output.append( { - "type": "reasoning", - "id": output_id("r"), - "status": "in_progress", - "start_tag": start_tag, - "end_tag": end_tag, - "attributes": attributes, - "content": [], - "summary": None, - "started_at": time.time(), + 'type': 'reasoning', + 'id': output_id('r'), + 'status': 'in_progress', + 'start_tag': start_tag, + 'end_tag': end_tag, + 'attributes': attributes, + 'content': [], + 'summary': None, + 'started_at': time.time(), } ) - elif output_item_type == "open_webui:code_interpreter": + elif output_item_type == 'open_webui:code_interpreter': output.append( { - "type": "open_webui:code_interpreter", - "id": output_id("ci"), - "status": "in_progress", - "start_tag": start_tag, - "end_tag": end_tag, - "attributes": attributes, - "lang": attributes.get("lang", "python"), - "code": "", - "output": None, - "started_at": time.time(), + 'type': 'open_webui:code_interpreter', + 'id': output_id('ci'), + 'status': 'in_progress', + 'start_tag': start_tag, + 'end_tag': end_tag, + 'attributes': attributes, + 'lang': attributes.get('lang', 'python'), + 'code': '', + 'output': None, + 'started_at': time.time(), } ) else: # solution or other text-producing tag output.append( { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ - {"type": "output_text", "text": ""} - ], - "_tag_type": content_type, - "start_tag": start_tag, - "end_tag": end_tag, - "attributes": attributes, - "started_at": time.time(), + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], + '_tag_type': content_type, + 'start_tag': start_tag, + 'end_tag': end_tag, + 'attributes': attributes, + 'started_at': time.time(), } ) if after_tag: # Set the after_tag content on the new item - if output_item_type == "reasoning": - output[-1]["content"] = [ - {"type": "output_text", "text": after_tag} - ] - elif output_item_type == "open_webui:code_interpreter": - output[-1]["code"] = after_tag + if output_item_type == 'reasoning': + output[-1]['content'] = [{'type': 'output_text', 'text': after_tag}] + elif output_item_type == 'open_webui:code_interpreter': + output[-1]['code'] = after_tag else: set_last_text(output, after_tag) - _, recursive_end = tag_output_handler( - content_type, tags, output - ) + _, recursive_end = tag_output_handler(content_type, tags, output) if recursive_end: end_flag = True break elif ( - (last_type == "reasoning" and content_type == "reasoning") - or ( - last_type == "open_webui:code_interpreter" - and content_type == "code_interpreter" - ) - or ( - last_type == "message" - and output[-1].get("_tag_type") == content_type - ) + (last_type == 'reasoning' and content_type == 'reasoning') + or (last_type == 'open_webui:code_interpreter' and content_type == 'code_interpreter') + or (last_type == 'message' and output[-1].get('_tag_type') == content_type) ): item = output[-1] - start_tag = item.get("start_tag", "") - end_tag = item.get("end_tag", "") + start_tag = item.get('start_tag', '') + end_tag = item.get('end_tag', '') - end_tag_pattern = rf"{re.escape(end_tag)}" + end_tag_pattern = rf'{re.escape(end_tag)}' # Get the block content from the item itself - if last_type == "reasoning": - parts = item.get("content", []) - block_content = "" - if parts and parts[-1].get("type") == "output_text": - block_content = parts[-1].get("text", "") - elif last_type == "open_webui:code_interpreter": - block_content = item.get("code", "") + if last_type == 'reasoning': + parts = item.get('content', []) + block_content = '' + if parts and parts[-1].get('type') == 'output_text': + block_content = parts[-1].get('text', '') + elif last_type == 'open_webui:code_interpreter': + block_content = item.get('code', '') else: block_content = get_last_text(output) @@ -3454,57 +3369,43 @@ def set_last_text(out, text): end_flag = True # Strip start and end tags from content - start_tag_pattern = rf"{re.escape(start_tag)}" - if start_tag.startswith("<") and start_tag.endswith(">"): - start_tag_pattern = ( - rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>" - ) - block_content = re.sub( - start_tag_pattern, "", block_content - ).strip() + start_tag_pattern = rf'{re.escape(start_tag)}' + if start_tag.startswith('<') and start_tag.endswith('>'): + start_tag_pattern = rf'<{re.escape(start_tag[1:-1])}(\s.*?)?>' + block_content = re.sub(start_tag_pattern, '', block_content).strip() end_tag_regex = re.compile(end_tag_pattern, re.DOTALL) split_content = end_tag_regex.split(block_content, maxsplit=1) - block_content = ( - split_content[0].strip() if split_content else "" - ) - leftover_content = ( - split_content[1].strip() if len(split_content) > 1 else "" - ) + block_content = split_content[0].strip() if split_content else '' + leftover_content = split_content[1].strip() if len(split_content) > 1 else '' if block_content: # Update the item with final content - if last_type == "reasoning": - item["content"] = [ - {"type": "output_text", "text": block_content} - ] - item["ended_at"] = time.time() - item["duration"] = int( - item["ended_at"] - item["started_at"] - ) - item["status"] = "completed" - elif last_type == "open_webui:code_interpreter": - item["code"] = block_content - item["ended_at"] = time.time() - item["duration"] = int( - item["ended_at"] - item["started_at"] - ) + if last_type == 'reasoning': + item['content'] = [{'type': 'output_text', 'text': block_content}] + item['ended_at'] = time.time() + item['duration'] = int(item['ended_at'] - item['started_at']) + item['status'] = 'completed' + elif last_type == 'open_webui:code_interpreter': + item['code'] = block_content + item['ended_at'] = time.time() + item['duration'] = int(item['ended_at'] - item['started_at']) else: set_last_text(output, block_content) - item["ended_at"] = time.time() + item['ended_at'] = time.time() # Reset by appending a new message item for leftover output.append( { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [ { - "type": "output_text", - "text": leftover_content, + 'type': 'output_text', + 'text': leftover_content, } ], } @@ -3514,14 +3415,14 @@ def set_last_text(out, text): output.pop() output.append( { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [ { - "type": "output_text", - "text": leftover_content, + 'type': 'output_text', + 'text': leftover_content, } ], } @@ -3529,29 +3430,23 @@ def set_last_text(out, text): return output, end_flag - message = Chats.get_message_by_id_and_message_id( - metadata["chat_id"], metadata["message_id"] - ) + message = Chats.get_message_by_id_and_message_id(metadata['chat_id'], metadata['message_id']) tool_calls = [] last_assistant_message = None try: - if form_data["messages"][-1]["role"] == "assistant": - last_assistant_message = get_last_assistant_message( - form_data["messages"] - ) + if form_data['messages'][-1]['role'] == 'assistant': + last_assistant_message = get_last_assistant_message(form_data['messages']) except Exception as e: pass content = ( - message.get("content", "") - if message - else last_assistant_message if last_assistant_message else "" + message.get('content', '') if message else last_assistant_message if last_assistant_message else '' ) # Initialize output: use existing from message if continuing, else create new - existing_output = message.get("output") if message else None + existing_output = message.get('output') if message else None if existing_output: output = existing_output else: @@ -3559,33 +3454,31 @@ def set_last_text(out, text): if content: output = [ { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [{"type": "output_text", "text": content}], + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': content}], } ] else: output = [] usage = None + prior_output = [] + last_response_id = None + + def full_output(): + return prior_output + output if prior_output else output - reasoning_tags_param = metadata.get("params", {}).get("reasoning_tags") + reasoning_tags_param = metadata.get('params', {}).get('reasoning_tags') DETECT_REASONING_TAGS = reasoning_tags_param is not False - DETECT_CODE_INTERPRETER = metadata.get("features", {}).get( - "code_interpreter", False - ) + DETECT_CODE_INTERPRETER = metadata.get('features', {}).get('code_interpreter', False) reasoning_tags = [] if DETECT_REASONING_TAGS: - if ( - isinstance(reasoning_tags_param, list) - and len(reasoning_tags_param) == 2 - ): - reasoning_tags = [ - (reasoning_tags_param[0], reasoning_tags_param[1]) - ] + if isinstance(reasoning_tags_param, list) and len(reasoning_tags_param) == 2: + reasoning_tags = [(reasoning_tags_param[0], reasoning_tags_param[1])] else: reasoning_tags = DEFAULT_REASONING_TAGS @@ -3593,15 +3486,15 @@ def set_last_text(out, text): for event in events: await event_emitter( { - "type": "chat:completion", - "data": event, + 'type': 'chat:completion', + 'data': event, } ) # Save message in the database Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { **event, }, @@ -3611,16 +3504,15 @@ async def stream_body_handler(response, form_data): nonlocal content nonlocal usage nonlocal output + nonlocal prior_output + nonlocal last_response_id response_tool_calls = [] delta_count = 0 delta_chunk_size = max( CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE, - int( - metadata.get("params", {}).get("stream_delta_chunk_size") - or 1 - ), + int(metadata.get('params', {}).get('stream_delta_chunk_size') or 1), ) last_delta_data = None @@ -3631,19 +3523,15 @@ async def flush_pending_delta_data(threshold: int = 0): if delta_count >= threshold and last_delta_data: await event_emitter( { - "type": "chat:completion", - "data": last_delta_data, + 'type': 'chat:completion', + 'data': last_delta_data, } ) delta_count = 0 last_delta_data = None async for line in response.body_iterator: - line = ( - line.decode("utf-8", "replace") - if isinstance(line, bytes) - else line - ) + line = line.decode('utf-8', 'replace') if isinstance(line, bytes) else line data = line # Skip empty lines @@ -3651,11 +3539,11 @@ async def flush_pending_delta_data(threshold: int = 0): continue # "data:" is the prefix for each event - if not data.startswith("data:"): + if not data.startswith('data:'): continue # Remove the prefix - data = data[len("data:") :].strip() + data = data[len('data:') :].strip() try: data = json.loads(data) @@ -3663,183 +3551,159 @@ async def flush_pending_delta_data(threshold: int = 0): data, _ = await process_filter_functions( request=request, filter_functions=filter_functions, - filter_type="stream", + filter_type='stream', form_data=data, - extra_params={"__body__": form_data, **extra_params}, + extra_params={'__body__': form_data, **extra_params}, ) if data: - if "event" in data and not getattr( - request.state, "direct", False - ): - await event_emitter(data.get("event", {})) + if 'event' in data and not getattr(request.state, 'direct', False): + await event_emitter(data.get('event', {})) - if "selected_model_id" in data: - model_id = data["selected_model_id"] + if 'selected_model_id' in data: + model_id = data['selected_model_id'] Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "selectedModelId": model_id, + 'selectedModelId': model_id, }, ) await event_emitter( { - "type": "chat:completion", - "data": data, + 'type': 'chat:completion', + 'data': data, } ) # Check for Responses API events (type field starts with "response.") - elif data.get("type", "").startswith("response."): - output, response_metadata = ( - handle_responses_streaming_event(data, output) - ) + elif data.get('type', '').startswith('response.'): + output, response_metadata = handle_responses_streaming_event(data, output) processed_data = { - "output": output, - "content": serialize_output(output), + 'output': full_output(), + 'content': serialize_output(full_output()), } # print(data) # print(processed_data) - # Merge any metadata (usage, done, etc.) + # Merge any metadata (usage, etc.) + # Strip 'done' โ€” response.completed emits + # it but we may still need to execute tool + # calls. The outer middleware manages the + # actual completion signal. if response_metadata: + if ENABLE_RESPONSES_API_STATEFUL: + response_id = response_metadata.pop('response_id', None) + if response_id: + last_response_id = response_id processed_data.update(response_metadata) + processed_data.pop('done', None) await event_emitter( { - "type": "chat:completion", - "data": processed_data, + 'type': 'chat:completion', + 'data': processed_data, } ) continue else: - choices = data.get("choices", []) + choices = data.get('choices', []) # Normalize usage data to standard format - raw_usage = data.get("usage", {}) or {} - raw_usage.update( - data.get("timings", {}) - ) # llama.cpp + raw_usage = data.get('usage', {}) or {} + raw_usage.update(data.get('timings', {})) # llama.cpp if raw_usage: usage = normalize_usage(raw_usage) await event_emitter( { - "type": "chat:completion", - "data": { - "usage": usage, + 'type': 'chat:completion', + 'data': { + 'usage': usage, }, } ) if not choices: - error = data.get("error", {}) + error = data.get('error', {}) if error: await event_emitter( { - "type": "chat:completion", - "data": { - "error": error, + 'type': 'chat:completion', + 'data': { + 'error': error, }, } ) continue - delta = choices[0].get("delta", {}) + delta = choices[0].get('delta', {}) # Handle delta annotations - annotations = delta.get("annotations") + annotations = delta.get('annotations') if annotations: for annotation in annotations: if ( - annotation.get("type") == "url_citation" - and "url_citation" in annotation + annotation.get('type') == 'url_citation' + and 'url_citation' in annotation ): - url_citation = annotation[ - "url_citation" - ] + url_citation = annotation['url_citation'] - url = url_citation.get("url", "") - title = url_citation.get("title", url) + url = url_citation.get('url', '') + title = url_citation.get('title', url) await event_emitter( { - "type": "source", - "data": { - "source": { - "name": title, - "url": url, + 'type': 'source', + 'data': { + 'source': { + 'name': title, + 'url': url, }, - "document": [title], - "metadata": [ + 'document': [title], + 'metadata': [ { - "source": url, - "name": title, + 'source': url, + 'name': title, } ], }, } ) - delta_tool_calls = delta.get("tool_calls", None) + delta_tool_calls = delta.get('tool_calls', None) if delta_tool_calls: for delta_tool_call in delta_tool_calls: - tool_call_index = delta_tool_call.get( - "index" - ) + tool_call_index = delta_tool_call.get('index') if tool_call_index is not None: # Check if the tool call already exists current_response_tool_call = None - for ( - response_tool_call - ) in response_tool_calls: - if ( - response_tool_call.get("index") - == tool_call_index - ): - current_response_tool_call = ( - response_tool_call - ) + for response_tool_call in response_tool_calls: + if response_tool_call.get('index') == tool_call_index: + current_response_tool_call = response_tool_call break if current_response_tool_call is None: # Add the new tool call - delta_tool_call.setdefault( - "function", {} - ) - delta_tool_call[ - "function" - ].setdefault("name", "") - delta_tool_call[ - "function" - ].setdefault("arguments", "") - response_tool_calls.append( - delta_tool_call - ) + delta_tool_call.setdefault('function', {}) + delta_tool_call['function'].setdefault('name', '') + delta_tool_call['function'].setdefault('arguments', '') + response_tool_calls.append(delta_tool_call) else: # Update the existing tool call - delta_name = delta_tool_call.get( - "function", {} - ).get("name") - delta_arguments = ( - delta_tool_call.get( - "function", {} - ).get("arguments") + delta_name = delta_tool_call.get('function', {}).get('name') + delta_arguments = delta_tool_call.get('function', {}).get( + 'arguments' ) if delta_name: - current_response_tool_call[ - "function" - ]["name"] = delta_name + current_response_tool_call['function']['name'] = delta_name if delta_arguments: - current_response_tool_call[ - "function" - ][ - "arguments" - ] += delta_arguments + current_response_tool_call['function']['arguments'] += ( + delta_arguments + ) # Emit pending tool calls in real-time if response_tool_calls: @@ -3849,44 +3713,34 @@ async def flush_pending_delta_data(threshold: int = 0): # Build pending function_call output items for display pending_fc_items = [] for tc in response_tool_calls: - call_id = tc.get("id", "") - func = tc.get("function", {}) + call_id = tc.get('id', '') + func = tc.get('function', {}) pending_fc_items.append( { - "type": "function_call", - "id": call_id - or output_id("fc"), - "call_id": call_id, - "name": func.get("name", ""), - "arguments": func.get( - "arguments", "{}" - ), - "status": "in_progress", + 'type': 'function_call', + 'id': call_id or output_id('fc'), + 'call_id': call_id, + 'name': func.get('name', ''), + 'arguments': func.get('arguments', '{}'), + 'status': 'in_progress', } ) - pending_output = output + pending_fc_items + await event_emitter( { - "type": "chat:completion", - "data": { - "content": serialize_output( - pending_output - ), + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(full_output() + pending_fc_items), }, } ) - image_urls = get_image_urls( - delta.get("images", []), request, metadata, user - ) + image_urls = get_image_urls(delta.get('images', []), request, metadata, user) if image_urls: - image_file_list = [ - {"type": "image", "url": url} - for url in image_urls - ] + image_file_list = [{'type': 'image', 'url': url} for url in image_urls] message_files = Chats.add_message_files_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], image_file_list, ) if message_files is None: @@ -3894,84 +3748,72 @@ async def flush_pending_delta_data(threshold: int = 0): await event_emitter( { - "type": "files", - "data": {"files": message_files}, + 'type': 'files', + 'data': {'files': message_files}, } ) - value = delta.get("content") + value = delta.get('content') reasoning_content = ( - delta.get("reasoning_content") - or delta.get("reasoning") - or delta.get("thinking") + delta.get('reasoning_content') + or delta.get('reasoning') + or delta.get('thinking') ) if reasoning_content: - if ( - not output - or output[-1].get("type") != "reasoning" - ): + if not output or output[-1].get('type') != 'reasoning': reasoning_item = { - "type": "reasoning", - "id": output_id("r"), - "status": "in_progress", - "start_tag": "", - "end_tag": "", - "attributes": { - "type": "reasoning_content" - }, - "content": [], - "summary": None, - "started_at": time.time(), + 'type': 'reasoning', + 'id': output_id('r'), + 'status': 'in_progress', + 'start_tag': '', + 'end_tag': '', + 'attributes': {'type': 'reasoning_content'}, + 'content': [], + 'summary': None, + 'started_at': time.time(), } output.append(reasoning_item) else: reasoning_item = output[-1] # Append to reasoning content - parts = reasoning_item.get("content", []) - if ( - parts - and parts[-1].get("type") == "output_text" - ): - parts[-1]["text"] += reasoning_content + parts = reasoning_item.get('content', []) + if parts and parts[-1].get('type') == 'output_text': + parts[-1]['text'] += reasoning_content else: - reasoning_item["content"] = [ + reasoning_item['content'] = [ { - "type": "output_text", - "text": reasoning_content, + 'type': 'output_text', + 'text': reasoning_content, } ] - data = {"content": serialize_output(output)} + data = {'content': serialize_output(full_output())} if value: if ( output - and output[-1].get("type") == "reasoning" - and output[-1] - .get("attributes", {}) - .get("type") - == "reasoning_content" + and output[-1].get('type') == 'reasoning' + and output[-1].get('attributes', {}).get('type') == 'reasoning_content' ): reasoning_item = output[-1] - reasoning_item["ended_at"] = time.time() - reasoning_item["duration"] = int( - reasoning_item["ended_at"] - - reasoning_item["started_at"] + reasoning_item['ended_at'] = time.time() + reasoning_item['duration'] = int( + reasoning_item['ended_at'] - reasoning_item['started_at'] ) - reasoning_item["status"] = "completed" + reasoning_item['status'] = 'completed' output.append( { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [ { - "type": "output_text", - "text": "", + 'type': 'output_text', + 'text': '', } ], } @@ -3982,17 +3824,13 @@ async def flush_pending_delta_data(threshold: int = 0): request, value, { - "chat_id": metadata.get( - "chat_id", None - ), - "message_id": metadata.get( - "message_id", None - ), + 'chat_id': metadata.get('chat_id', None), + 'message_id': metadata.get('message_id', None), }, user, ) - content = f"{content}{value}" + content = f'{content}{value}' # Check if we're inside a tag-based block # (reasoning, code_interpreter, or solution). @@ -4002,122 +3840,93 @@ async def flush_pending_delta_data(threshold: int = 0): # start tag on every chunk and fragments the # output. last_item = output[-1] if output else None - last_item_type = ( - last_item.get("type", "") - if last_item - else "" - ) + last_item_type = last_item.get('type', '') if last_item else '' inside_tag_block = ( last_item is not None - and last_item.get("status") == "in_progress" - and last_item.get("attributes", {}).get( - "type" - ) - != "reasoning_content" + and last_item.get('status') == 'in_progress' + and last_item.get('attributes', {}).get('type') != 'reasoning_content' and ( - last_item_type == "reasoning" - or last_item_type - == "open_webui:code_interpreter" + last_item_type == 'reasoning' + or last_item_type == 'open_webui:code_interpreter' or ( - last_item_type == "message" - and last_item.get("_tag_type") - is not None + last_item_type == 'message' + and last_item.get('_tag_type') is not None ) ) ) if inside_tag_block: # Append to the existing tag-based item - if ( - last_item_type - == "open_webui:code_interpreter" - ): - last_item["code"] = ( - last_item.get("code", "") + value - ) - elif last_item_type == "reasoning": - parts = last_item.get("content", []) - if ( - parts - and parts[-1].get("type") - == "output_text" - ): - parts[-1]["text"] += value + if last_item_type == 'open_webui:code_interpreter': + last_item['code'] = last_item.get('code', '') + value + elif last_item_type == 'reasoning': + parts = last_item.get('content', []) + if parts and parts[-1].get('type') == 'output_text': + parts[-1]['text'] += value else: - last_item["content"] = [ + last_item['content'] = [ { - "type": "output_text", - "text": value, + 'type': 'output_text', + 'text': value, } ] else: # solution or other _tag_type message - msg_parts = last_item.get("content", []) - if ( - msg_parts - and msg_parts[-1].get("type") - == "output_text" - ): - msg_parts[-1]["text"] += value + msg_parts = last_item.get('content', []) + if msg_parts and msg_parts[-1].get('type') == 'output_text': + msg_parts[-1]['text'] += value else: - last_item["content"] = [ + last_item['content'] = [ { - "type": "output_text", - "text": value, + 'type': 'output_text', + 'text': value, } ] else: - if ( - not output - or output[-1].get("type") != "message" - ): + if not output or output[-1].get('type') != 'message': output.append( { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [ { - "type": "output_text", - "text": "", + 'type': 'output_text', + 'text': '', } ], } ) # Append value to last message item's text - msg_parts = output[-1].get("content", []) - if ( - msg_parts - and msg_parts[-1].get("type") - == "output_text" - ): - msg_parts[-1]["text"] += value + msg_parts = output[-1].get('content', []) + if msg_parts and msg_parts[-1].get('type') == 'output_text': + msg_parts[-1]['text'] += value else: - output[-1]["content"] = [ + output[-1]['content'] = [ { - "type": "output_text", - "text": value, + 'type': 'output_text', + 'text': value, } ] if DETECT_REASONING_TAGS: output, _ = tag_output_handler( - "reasoning", + 'reasoning', reasoning_tags, output, ) output, _ = tag_output_handler( - "solution", + 'solution', DEFAULT_SOLUTION_TAGS, output, ) if DETECT_CODE_INTERPRETER: output, end = tag_output_handler( - "code_interpreter", + 'code_interpreter', DEFAULT_CODE_INTERPRETER_TAGS, output, ) @@ -4128,16 +3937,16 @@ async def flush_pending_delta_data(threshold: int = 0): if ENABLE_REALTIME_CHAT_SAVE: # Save message in the database Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "content": serialize_output(output), - "output": output, + 'content': serialize_output(full_output()), + 'output': full_output(), }, ) else: data = { - "content": serialize_output(output), + 'content': serialize_output(full_output()), } if delta: @@ -4148,55 +3957,81 @@ async def flush_pending_delta_data(threshold: int = 0): else: await event_emitter( { - "type": "chat:completion", - "data": data, + 'type': 'chat:completion', + 'data': data, } ) except Exception as e: - done = "data: [DONE]" in line + done = 'data: [DONE]' in line if done: pass else: - log.debug(f"Error: {e}") + log.debug(f'Error: {e}') continue await flush_pending_delta_data() if output: # Clean up the last message item - if output[-1].get("type") == "message": - parts = output[-1].get("content", []) - if parts and parts[-1].get("type") == "output_text": - parts[-1]["text"] = parts[-1]["text"].strip() + if output[-1].get('type') == 'message': + parts = output[-1].get('content', []) + if parts and parts[-1].get('type') == 'output_text': + parts[-1]['text'] = parts[-1]['text'].strip() - if not parts[-1]["text"]: + if not parts[-1]['text']: output.pop() if not output: output.append( { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [ - {"type": "output_text", "text": ""} - ], + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], } ) - if output[-1].get("type") == "reasoning": + if output[-1].get('type') == 'reasoning': reasoning_item = output[-1] - if reasoning_item.get("ended_at") is None: - reasoning_item["ended_at"] = time.time() - reasoning_item["duration"] = int( - reasoning_item["ended_at"] - - reasoning_item["started_at"] + if reasoning_item.get('ended_at') is None: + reasoning_item['ended_at'] = time.time() + reasoning_item['duration'] = int( + reasoning_item['ended_at'] - reasoning_item['started_at'] ) - reasoning_item["status"] = "completed" + reasoning_item['status'] = 'completed' if response_tool_calls: tool_calls.append(_split_tool_calls(response_tool_calls)) + # Responses API path: extract function_call items from output + if not response_tool_calls and output: + # Collect call_ids that already have results, + # including those from prior_output so we don't + # re-process tool calls from a previous turn. + handled_call_ids = { + item.get('call_id') + for item in (prior_output + output) + if item.get('type') == 'function_call_output' + } + responses_api_tool_calls = [] + for item in output: + if item.get('type') == 'function_call' and item.get('call_id') not in handled_call_ids: + arguments = item.get('arguments', '{}') + responses_api_tool_calls.append( + { + 'id': item.get('call_id', ''), + 'index': len(responses_api_tool_calls), + 'function': { + 'name': item.get('name', ''), + 'arguments': ( + arguments if isinstance(arguments, str) else json.dumps(arguments) + ), + }, + } + ) + if responses_api_tool_calls: + tool_calls.append(_split_tool_calls(responses_api_tool_calls)) + if response.background: await response.background() @@ -4205,69 +4040,64 @@ async def flush_pending_delta_data(threshold: int = 0): tool_call_retries = 0 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"]) + user_message = get_last_user_message(form_data['messages']) # Check if citations are enabled for this model - citations_enabled = ( - model.get("info", {}).get("meta", {}).get("capabilities") or {} - ).get("citations", True) + citations_enabled = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get( + 'citations', True + ) # Use the pre-RAG system content captured before the # initial file-source injection in process_chat_payload. # This ensures restore truly undoes the RAG template. - original_system_content = metadata.get("system_prompt") + original_system_content = metadata.get('system_prompt') if original_system_content is None: - original_system_message = get_system_message(form_data["messages"]) + original_system_message = get_system_message(form_data['messages']) original_system_content = ( - get_content_from_message(original_system_message) - if original_system_message - else None + get_content_from_message(original_system_message) if original_system_message else None ) - while ( - len(tool_calls) > 0 - and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES - ): - + while len(tool_calls) > 0 and tool_call_retries < CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES: tool_call_retries += 1 response_tool_calls = tool_calls.pop(0) # Append function_call items for each tool call + # (Responses API already has them from streaming, so skip duplicates) + existing_call_ids = {item.get('call_id') for item in output if item.get('type') == 'function_call'} for tc in response_tool_calls: - call_id = tc.get("id", "") - func = tc.get("function", {}) - output.append( - { - "type": "function_call", - "id": call_id or output_id("fc"), - "call_id": call_id, - "name": func.get("name", ""), - "arguments": func.get("arguments", "{}"), - "status": "in_progress", - } - ) + call_id = tc.get('id', '') + if call_id not in existing_call_ids: + func = tc.get('function', {}) + output.append( + { + 'type': 'function_call', + 'id': call_id or output_id('fc'), + 'call_id': call_id, + 'name': func.get('name', ''), + 'arguments': func.get('arguments', '{}'), + 'status': 'in_progress', + } + ) await event_emitter( { - "type": "chat:completion", - "data": { - "content": serialize_output(output), - "output": output, + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(full_output()), + 'output': full_output(), }, } ) - tools = metadata.get("tools", {}) + tools = metadata.get('tools', {}) results = [] 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_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(): @@ -4280,24 +4110,18 @@ async def flush_pending_delta_data(threshold: int = 0): try: tool_function_params = json.loads(tool_args) except Exception as e: - log.error( - f"Error parsing tool call arguments: {tool_args}" - ) + 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.", + '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 - ) + 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 @@ -4306,68 +4130,54 @@ async def flush_pending_delta_data(threshold: int = 0): if tool_function_name in tools: tool = tools[tool_function_name] - spec = tool.get("spec", {}) + spec = tool.get('spec', {}) - tool_type = tool.get("type", "") - direct_tool = tool.get("direct", False) + tool_type = tool.get('type', '') + direct_tool = tool.get('direct', False) try: - allowed_params = ( - spec.get("parameters", {}) - .get("properties", {}) - .keys() - ) + 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 + k: v for k, v in tool_function_params.items() if k in allowed_params } 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 - ), + '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 = get_updated_tool_function( - function=tool["callable"], + function=tool['callable'], extra_params={ - "__messages__": form_data.get( - "messages", [] - ), - "__files__": metadata.get("files", []), + '__messages__': form_data.get('messages', []), + '__files__': metadata.get('files', []), }, ) - tool_result = await tool_function( - **tool_function_params - ) + tool_result = await tool_function(**tool_function_params) except Exception as e: tool_result = str(e) - tool_result, tool_result_files, tool_result_embeds = ( - process_tool_result( - request, - tool_function_name, - tool_result, - tool_type, - direct_tool, - metadata, - user, - ) + tool_result, tool_result_files, tool_result_embeds = process_tool_result( + request, + tool_function_name, + tool_result, + tool_type, + direct_tool, + metadata, + user, ) await terminal_event_handler( @@ -4382,10 +4192,11 @@ async def flush_pending_delta_data(threshold: int = 0): citations_enabled and tool_function_name in [ - "search_web", - "fetch_url", - "view_knowledge_file", - "query_knowledge_files", + 'search_web', + 'fetch_url', + 'view_file', + 'view_knowledge_file', + 'query_knowledge_files', ] and tool_result ): @@ -4394,86 +4205,73 @@ async def flush_pending_delta_data(threshold: int = 0): tool_name=tool_function_name, tool_params=tool_function_params, tool_result=tool_result, - tool_id=tool.get("tool_id", "") if tool else "", + tool_id=tool.get('tool_id', '') if tool else '', ) tool_call_sources.extend(citation_sources) except Exception as e: - log.exception(f"Error extracting citation source: {e}") + log.exception(f'Error extracting citation source: {e}') results.append( { - "tool_call_id": tool_call_id, - "content": str(tool_result) if tool_result else "", - **( - {"files": tool_result_files} - if tool_result_files - else {} - ), - **( - {"embeds": tool_result_embeds} - if tool_result_embeds - else {} - ), + 'tool_call_id': tool_call_id, + 'content': str(tool_result) if tool_result else '', + **({'files': tool_result_files} if tool_result_files else {}), + **({'embeds': tool_result_embeds} if tool_result_embeds else {}), } ) # Update function_call statuses and append function_call_output items for tc in response_tool_calls: - call_id = tc.get("id", "") + call_id = tc.get('id', '') # Mark function_call as completed for item in output: - if ( - item.get("type") == "function_call" - and item.get("call_id") == call_id - ): - item["status"] = "completed" + if item.get('type') == 'function_call' and item.get('call_id') == call_id: + item['status'] = 'completed' # Update arguments with parsed/sanitized version - item["arguments"] = tc.get("function", {}).get( - "arguments", "{}" - ) + item['arguments'] = tc.get('function', {}).get('arguments', '{}') break for result in results: + output_parts = [{'type': 'input_text', 'text': result.get('content', '')}] + + # Separate image data URIs (for LLM via input_image) from + # other files (for frontend display via files attribute). + display_files = [] + for file_item in result.get('files', []): + if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'): + # LLM-only: add as input_image part (invisible to serialize_output) + output_parts.append({'type': 'input_image', 'image_url': file_item['url']}) + else: + # Frontend display (MCP images, audio, etc.) + display_files.append(file_item) + output.append( { - "type": "function_call_output", - "id": output_id("fco"), - "call_id": result.get("tool_call_id", ""), - "output": [ - { - "type": "input_text", - "text": result.get("content", ""), - } - ], - "status": "completed", - **( - {"files": result.get("files")} - if result.get("files") - else {} - ), - **( - {"embeds": result.get("embeds")} - if result.get("embeds") - else {} - ), + 'type': 'function_call_output', + 'id': output_id('fco'), + 'call_id': result.get('tool_call_id', ''), + 'output': output_parts, + 'status': 'completed', + **({'files': display_files} if display_files else {}), + **({'embeds': result.get('embeds')} if result.get('embeds') else {}), } ) # Append a new empty message item for the next response output.append( { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [{"type": "output_text", "text": ""}], + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], } ) # Emit citation sources to the frontend for display if citations_enabled: for source in tool_call_sources: - await event_emitter({"type": "source", "data": source}) + await event_emitter({'type': 'source', 'data': source}) # Apply tool source context to messages for the model. # Restoring to pre-RAG original prevents duplicating @@ -4482,23 +4280,21 @@ async def flush_pending_delta_data(threshold: int = 0): if all_tool_call_sources and user_message: # Restore pre-RAG message state before re-applying # to prevent RAG template duplication. - original_user_message = ( - metadata.get("user_prompt") or user_message - ) + original_user_message = metadata.get('user_prompt') or user_message set_last_user_message_content( original_user_message, - form_data["messages"], + form_data['messages'], ) replace_system_message_content( - original_system_content or "", - form_data["messages"], + original_system_content or '', + form_data['messages'], ) # Build context: file sources with content, # tool sources as citation markers only. source_ids = {} source_context = get_source_context( - metadata.get("sources", []), source_ids + metadata.get('sources', []), source_ids ) + get_source_context( all_tool_call_sources, source_ids, @@ -4512,27 +4308,36 @@ async def flush_pending_delta_data(threshold: int = 0): user_message, ) if RAG_SYSTEM_CONTEXT: - form_data["messages"] = ( - add_or_update_system_message( - rag_content, - form_data["messages"], - append=True, - ) + form_data['messages'] = add_or_update_system_message( + rag_content, + form_data['messages'], + append=True, ) else: - form_data["messages"] = add_or_update_user_message( + form_data['messages'] = add_or_update_user_message( rag_content, - form_data["messages"], + form_data['messages'], append=False, ) tool_call_sources.clear() + # Strip input_image parts (large base64 data URIs) from the + # output sent to the frontend โ€” they're only for LLM consumption + # via convert_output_to_messages. + frontend_output = [] + for item in output: + if item.get('type') == 'function_call_output': + parts = item.get('output', []) + if any(p.get('type') == 'input_image' for p in parts): + item = {**item, 'output': [p for p in parts if p.get('type') != 'input_image']} + frontend_output.append(item) + await event_emitter( { - "type": "chat:completion", - "data": { - "content": serialize_output(output), - "output": output, + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(output), + 'output': frontend_output, }, } ) @@ -4540,14 +4345,51 @@ async def flush_pending_delta_data(threshold: int = 0): try: new_form_data = { **form_data, - "model": model_id, - "stream": True, - "messages": [ - *form_data["messages"], - *convert_output_to_messages(output, raw=True), - ], + 'model': model_id, + 'stream': True, } + if ENABLE_RESPONSES_API_STATEFUL and last_response_id: + system_message = get_system_message(form_data['messages']) + new_form_data['messages'] = ( + [system_message] if system_message else [] + ) + convert_output_to_messages(output, raw=True) + new_form_data['previous_response_id'] = last_response_id + else: + tool_messages = convert_output_to_messages(output, raw=True) + + # Chat Completions providers don't support multimodal + # tool messages. Extract images into a user message. + image_urls = [] + for message in tool_messages: + if message.get('role') == 'tool' and isinstance(message.get('content'), list): + text_parts = [] + for part in message['content']: + if part.get('type') == 'input_text': + text_parts.append(part.get('text', '')) + elif part.get('type') == 'input_image': + image_urls.append(part.get('image_url', '')) + message['content'] = ''.join(text_parts) + + new_form_data['messages'] = [ + *form_data['messages'], + *tool_messages, + ] + + if image_urls: + new_form_data['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 image_urls], + ], + } + ) + res = await generate_chat_completion( request, new_form_data, @@ -4556,7 +4398,28 @@ async def flush_pending_delta_data(threshold: int = 0): ) if isinstance(res, StreamingResponse): + # Save accumulated output and start fresh. + # Responses API output_index values are relative + # to the current response โ€” a clean output list + # keeps indices aligned. The display prefix + # ensures the UI shows tool history during + # streaming. + prior_output = list(output) + # Trim the trailing empty placeholder message + # so it doesn't persist as a ghost item once + # the new stream produces real content. + if ( + prior_output + and prior_output[-1].get('type') == 'message' + and prior_output[-1].get('status') == 'in_progress' + ): + msg_parts = prior_output[-1].get('content', []) + if not msg_parts or (len(msg_parts) == 1 and not msg_parts[0].get('text', '').strip()): + prior_output.pop() + output = [] await stream_body_handler(res, new_form_data) + output[:0] = prior_output + prior_output = [] else: break except Exception as e: @@ -4567,35 +4430,31 @@ async def flush_pending_delta_data(threshold: int = 0): MAX_RETRIES = 5 retries = 0 - while ( - output - and output[-1].get("type") == "open_webui:code_interpreter" - and retries < MAX_RETRIES - ): - + while output and output[-1].get('type') == 'open_webui:code_interpreter' and retries < MAX_RETRIES: await event_emitter( { - "type": "chat:completion", - "data": { - "content": serialize_output(output), - "output": output, + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(output), + 'output': output, }, } ) retries += 1 - log.debug(f"Attempt count: {retries}") + log.debug(f'Attempt count: {retries}') ci_item = output[-1] - ci_output = "" + ci_output = '' try: - if ci_item.get("attributes", {}).get("type") == "code": - code = ci_item.get("code", "") + if ci_item.get('attributes', {}).get('type') == 'code': + code = ci_item.get('code', '') # Sanitize code (strips ANSI codes and markdown fences) code = sanitize_code(code) if CODE_INTERPRETER_BLOCKED_MODULES: - blocking_code = textwrap.dedent(f""" + blocking_code = textwrap.dedent( + f""" import builtins BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES} @@ -4611,62 +4470,50 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): return _real_import(name, globals, locals, fromlist, level) builtins.__import__ = restricted_import - """) - code = blocking_code + "\n" + code + """ + ) + code = blocking_code + '\n' + code - if ( - request.app.state.config.CODE_INTERPRETER_ENGINE - == "pyodide" - ): + if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide': ci_output = await event_caller( { - "type": "execute:python", - "data": { - "id": str(uuid4()), - "code": code, - "session_id": metadata.get( - "session_id", None - ), - "files": metadata.get("files", []), + 'type': 'execute:python', + 'data': { + 'id': str(uuid4()), + 'code': code, + 'session_id': metadata.get('session_id', None), + 'files': metadata.get('files', []), }, } ) - elif ( - request.app.state.config.CODE_INTERPRETER_ENGINE - == "jupyter" - ): + elif request.app.state.config.CODE_INTERPRETER_ENGINE == 'jupyter': ci_output = await execute_code_jupyter( request.app.state.config.CODE_INTERPRETER_JUPYTER_URL, code, ( request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_TOKEN - if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH - == "token" + if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'token' else None ), ( request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH_PASSWORD - if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH - == "password" + if request.app.state.config.CODE_INTERPRETER_JUPYTER_AUTH == 'password' else None ), request.app.state.config.CODE_INTERPRETER_JUPYTER_TIMEOUT, ) else: - ci_output = { - "stdout": "Code interpreter engine not configured." - } + ci_output = {'stdout': 'Code interpreter engine not configured.'} - log.debug(f"Code interpreter output: {ci_output}") + log.debug(f'Code interpreter output: {ci_output}') if isinstance(ci_output, dict): - stdout = ci_output.get("stdout", "") + stdout = ci_output.get('stdout', '') if isinstance(stdout, str): - stdoutLines = stdout.split("\n") + stdoutLines = stdout.split('\n') for idx, line in enumerate(stdoutLines): - - if "data:image/png;base64" in line: + if re.match(r'data:image/\w+;base64', line): image_url = get_image_url_from_base64( request, line, @@ -4674,50 +4521,46 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): user, ) if image_url: - stdoutLines[idx] = ( - f"![Output Image]({image_url})" - ) + stdoutLines[idx] = f'![Output Image]({image_url})' - ci_output["stdout"] = "\n".join(stdoutLines) + ci_output['stdout'] = '\n'.join(stdoutLines) - result = ci_output.get("result", "") + result = ci_output.get('result', '') if isinstance(result, str): - resultLines = result.split("\n") + resultLines = result.split('\n') for idx, line in enumerate(resultLines): - if "data:image/png;base64" in line: + if re.match(r'data:image/\w+;base64', line): image_url = get_image_url_from_base64( request, line, metadata, user, ) - resultLines[idx] = ( - f"![Output Image]({image_url})" - ) - ci_output["result"] = "\n".join(resultLines) + resultLines[idx] = f'![Output Image]({image_url})' + ci_output['result'] = '\n'.join(resultLines) except Exception as e: ci_output = str(e) - ci_item["output"] = ci_output - ci_item["status"] = "completed" + ci_item['output'] = ci_output + ci_item['status'] = 'completed' output.append( { - "type": "message", - "id": output_id("msg"), - "status": "in_progress", - "role": "assistant", - "content": [{"type": "output_text", "text": ""}], + 'type': 'message', + 'id': output_id('msg'), + 'status': 'in_progress', + 'role': 'assistant', + 'content': [{'type': 'output_text', 'text': ''}], } ) await event_emitter( { - "type": "chat:completion", - "data": { - "content": serialize_output(output), - "output": output, + 'type': 'chat:completion', + 'data': { + 'content': serialize_output(output), + 'output': output, }, } ) @@ -4725,10 +4568,10 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): try: new_form_data = { **form_data, - "model": model_id, - "stream": True, - "messages": [ - *form_data["messages"], + 'model': model_id, + 'stream': True, + 'messages': [ + *form_data['messages'], *convert_output_to_messages(output, raw=True), ], } @@ -4750,73 +4593,87 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): # Mark all in-progress items as completed for item in output: - if item.get("status") == "in_progress": - item["status"] = "completed" + if item.get('status') == 'in_progress': + item['status'] = 'completed' - title = Chats.get_chat_title_by_id(metadata["chat_id"]) + title = Chats.get_chat_title_by_id(metadata['chat_id']) data = { - "done": True, - "content": serialize_output(output), - "output": output, - "title": title, + 'done': True, + 'content': serialize_output(output), + 'output': output, + 'title': title, } if not ENABLE_REALTIME_CHAT_SAVE: # Save message in the database Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "content": serialize_output(output), - "output": output, - **({"usage": usage} if usage else {}), + 'done': True, + 'content': serialize_output(output), + 'output': output, + **({'usage': usage} if usage else {}), }, ) elif usage: Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], - {"usage": usage}, + metadata['chat_id'], + metadata['message_id'], + {'done': True, 'usage': usage}, + ) + else: + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, ) # Send a webhook notification if the user is not active - if not Users.is_user_active(user.id): + if request.app.state.config.ENABLE_USER_WEBHOOKS and not Users.is_user_active(user.id): webhook_url = Users.get_user_webhook_url_by_id(user.id) if webhook_url: await post_webhook( request.app.state.WEBUI_NAME, webhook_url, - f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}", + f'{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}\n\n{content}', { - "action": "chat", - "message": content, - "title": title, - "url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}", + 'action': 'chat', + 'message': content, + 'title': title, + 'url': f'{request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}', }, ) await event_emitter( { - "type": "chat:completion", - "data": data, + 'type': 'chat:completion', + 'data': data, } ) await background_tasks_handler(ctx) except asyncio.CancelledError: - log.warning("Task was cancelled!") - await event_emitter({"type": "chat:tasks:cancel"}) + log.warning('Task was cancelled!') + await event_emitter({'type': 'chat:tasks:cancel'}) if not ENABLE_REALTIME_CHAT_SAVE: # Save message in the database Chats.upsert_message_to_chat_by_id_and_message_id( - metadata["chat_id"], - metadata["message_id"], + metadata['chat_id'], + metadata['message_id'], { - "content": serialize_output(output), - "output": output, + 'done': True, + 'content': serialize_output(output), + 'output': output, }, ) + else: + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata['chat_id'], + metadata['message_id'], + {'done': True}, + ) if response.background is not None: await response.background() @@ -4827,13 +4684,13 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0): # Fallback to the original response async def stream_wrapper(original_generator, events): def wrap_item(item): - return f"data: {item}\n\n" + return f'data: {item}\n\n' for event in events: event, _ = await process_filter_functions( request=request, filter_functions=filter_functions, - filter_type="stream", + filter_type='stream', form_data=event, extra_params=extra_params, ) @@ -4845,7 +4702,7 @@ def wrap_item(item): data, _ = await process_filter_functions( request=request, filter_functions=filter_functions, - filter_type="stream", + filter_type='stream', form_data=data, extra_params=extra_params, ) @@ -4867,8 +4724,8 @@ async def process_chat_response(response, ctx): # Non standard response if not any( - content_type in response.headers["Content-Type"] - for content_type in ["text/event-stream", "application/x-ndjson"] + content_type in response.headers['Content-Type'] + for content_type in ['text/event-stream', 'application/x-ndjson'] ): return response diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 75ad086da1..cecc2c47c3 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -32,7 +32,7 @@ def get_allow_block_lists(filter_list): if filter_list: for d in filter_list: - if d.startswith("!"): + if d.startswith('!'): # Domains starting with "!" โ†’ blocked block_list.append(d[1:].strip()) else: @@ -42,9 +42,7 @@ def get_allow_block_lists(filter_list): return allow_list, block_list -def is_string_allowed( - string: Union[str, Sequence[str]], filter_list: Optional[list[str]] = None -) -> bool: +def is_string_allowed(string: Union[str, Sequence[str]], filter_list: Optional[list[str]] = None) -> bool: """ Checks if a string is allowed based on the provided filter list. :param string: The string or sequence of strings to check (e.g., domain or hostname). @@ -93,7 +91,7 @@ def get_message_list(messages_map, message_id): visited_message_ids = set() while current_message: - message_id = current_message.get("id") + message_id = current_message.get('id') if message_id in visited_message_ids: # Cycle detected, break to prevent infinite loop break @@ -102,7 +100,7 @@ def get_message_list(messages_map, message_id): visited_message_ids.add(message_id) message_list.append(current_message) - parent_id = current_message.get("parentId") # Use .get() for safety + parent_id = current_message.get('parentId') # Use .get() for safety current_message = messages_map.get(parent_id) if parent_id else None message_list.reverse() @@ -110,28 +108,23 @@ def get_message_list(messages_map, message_id): def get_messages_content(messages: list[dict]) -> str: - return "\n".join( - [ - f"{message['role'].upper()}: {get_content_from_message(message)}" - for message in messages - ] - ) + return '\n'.join([f'{message["role"].upper()}: {get_content_from_message(message)}' for message in messages]) def get_last_user_message_item(messages: list[dict]) -> Optional[dict]: for message in reversed(messages): - if message["role"] == "user": + if message['role'] == 'user': return message return None def get_content_from_message(message: dict) -> Optional[str]: - if isinstance(message.get("content"), list): - for item in message["content"]: - if item["type"] == "text": - return item["text"] + if isinstance(message.get('content'), list): + for item in message['content']: + if item['type'] == 'text': + return item['text'] else: - return message.get("content") + return message.get('content') return None @@ -160,111 +153,119 @@ def flush_pending(): if pending_content or pending_tool_calls: messages.append( { - "role": "assistant", - "content": "\n".join(pending_content) if pending_content else "", - **( - {"tool_calls": pending_tool_calls} if pending_tool_calls else {} - ), + 'role': 'assistant', + 'content': '\n'.join(pending_content) if pending_content else '', + **({'tool_calls': pending_tool_calls} if pending_tool_calls else {}), } ) pending_content = [] pending_tool_calls = [] for item in output: - item_type = item.get("type", "") + item_type = item.get('type', '') - if item_type == "message": + if item_type == 'message': # Extract text from output_text content parts - content_parts = item.get("content", []) - text = "" + content_parts = item.get('content', []) + text = '' for part in content_parts: - if part.get("type") == "output_text": - text += part.get("text", "") + if part.get('type') == 'output_text': + text += part.get('text', '') if text: pending_content.append(text) - elif item_type == "function_call": + elif item_type == 'function_call': # Collect tool calls to batch into assistant message - arguments = item.get("arguments", "{}") + arguments = item.get('arguments', '{}') # Ensure arguments is always a JSON string if not isinstance(arguments, str): arguments = json.dumps(arguments) pending_tool_calls.append( { - "id": item.get("call_id", ""), - "type": "function", - "function": { - "name": item.get("name", ""), - "arguments": arguments, + 'id': item.get('call_id', ''), + 'type': 'function', + 'function': { + 'name': item.get('name', ''), + 'arguments': arguments, }, } ) - elif item_type == "function_call_output": + elif item_type == 'function_call_output': # Flush any pending content/tool_calls before adding tool result flush_pending() - # Extract text from output content parts - output_parts = item.get("output", []) - content = "" + # Extract text and images from output content parts + output_parts = item.get('output', []) + content = '' + image_urls = [] for part in output_parts: - if part.get("type") == "input_text": - output_text = part.get("text", "") - content += ( - str(output_text) - if not isinstance(output_text, str) - else output_text - ) - - messages.append( - { - "role": "tool", - "tool_call_id": item.get("call_id", ""), - "content": content, - } - ) + if part.get('type') == 'input_text': + output_text = part.get('text', '') + content += str(output_text) if not isinstance(output_text, str) else output_text + elif part.get('type') == 'input_image': + url = part.get('image_url', '') + if url: + image_urls.append(url) + + if image_urls: + # Multimodal tool content with image(s) + messages.append( + { + 'role': 'tool', + 'tool_call_id': item.get('call_id', ''), + 'content': [ + {'type': 'input_text', 'text': content}, + *[{'type': 'input_image', 'image_url': url} for url in image_urls], + ], + } + ) + else: + messages.append( + { + 'role': 'tool', + 'tool_call_id': item.get('call_id', ''), + 'content': content, + } + ) - elif item_type == "reasoning": + elif item_type == 'reasoning': if raw: # Include reasoning with original tags for LLM re-processing - reasoning_text = "" - source_list = item.get("summary", []) or item.get("content", []) + reasoning_text = '' + source_list = item.get('summary', []) or item.get('content', []) for part in source_list: - if part.get("type") == "output_text": - reasoning_text += part.get("text", "") - elif "text" in part: - reasoning_text += part.get("text", "") + if part.get('type') == 'output_text': + reasoning_text += part.get('text', '') + elif 'text' in part: + reasoning_text += part.get('text', '') if reasoning_text: - start_tag = item.get("start_tag", "") - end_tag = item.get("end_tag", "") - pending_content.append(f"{start_tag}{reasoning_text}{end_tag}") + start_tag = item.get('start_tag', '') + end_tag = item.get('end_tag', '') + pending_content.append(f'{start_tag}{reasoning_text}{end_tag}') # else: skip reasoning blocks for normal LLM messages - elif item_type == "open_webui:code_interpreter": + elif item_type == 'open_webui:code_interpreter': # Always include code interpreter content so the LLM knows # the code was already executed and doesn't retry. - code = item.get("code", "") - code_output = item.get("output", "") + code = item.get('code', '') + code_output = item.get('output', '') if code: - pending_content.append( - f"\n{code}\n" - ) + pending_content.append(f'\n{code}\n') if code_output: if isinstance(code_output, dict): - stdout = code_output.get("stdout", "") - result = code_output.get("result", "") + stdout = code_output.get('stdout', '') + result = code_output.get('result', '') output_text = stdout or result else: output_text = str(code_output) if output_text: - pending_content.append( - f"\n{output_text}\n" - ) + pending_content.append(f'\n{output_text}\n') - elif item_type.startswith("open_webui:"): + elif item_type.startswith('open_webui:'): # Skip other extension types pass @@ -287,74 +288,99 @@ def set_last_user_message_content(content: str, messages: list[dict]) -> list[di Handles both plain-string and list-of-parts content formats. """ for message in reversed(messages): - if message.get("role") == "user": - if isinstance(message.get("content"), list): - for item in message["content"]: - if item.get("type") == "text": - item["text"] = content + if message.get('role') == 'user': + if isinstance(message.get('content'), list): + for item in message['content']: + if item.get('type') == 'text': + item['text'] = content break else: - message["content"] = content + message['content'] = content break return messages def get_last_assistant_message_item(messages: list[dict]) -> Optional[dict]: for message in reversed(messages): - if message["role"] == "assistant": + if message['role'] == 'assistant': return message return None def get_last_assistant_message(messages: list[dict]) -> Optional[str]: for message in reversed(messages): - if message["role"] == "assistant": + if message['role'] == 'assistant': return get_content_from_message(message) return None def get_system_message(messages: list[dict]) -> Optional[dict]: for message in messages: - if message["role"] == "system": + if message['role'] == 'system': return message return None def remove_system_message(messages: list[dict]) -> list[dict]: - return [message for message in messages if message["role"] != "system"] + return [message for message in messages if message['role'] != 'system'] def pop_system_message(messages: list[dict]) -> tuple[Optional[dict], list[dict]]: return get_system_message(messages), remove_system_message(messages) +def merge_system_messages(messages: list[dict]) -> list[dict]: + """ + Merge all system messages into one at position 0. + + Some chat templates (e.g. Qwen) require exactly one system + message at the start. Multiple pipeline stages may each + insert their own system message; this function consolidates + them. + """ + system_contents: list[str] = [] + other_messages: list[dict] = [] + + for message in messages: + if message.get('role') == 'system': + content = get_content_from_message(message) + if content: + system_contents.append(content) + else: + other_messages.append(message) + + if not system_contents: + return other_messages + + merged = {'role': 'system', 'content': '\n'.join(system_contents)} + return [merged, *other_messages] + + def update_message_content(message: dict, content: str, append: bool = True) -> dict: - if isinstance(message["content"], list): - for item in message["content"]: - if item["type"] == "text": + if isinstance(message['content'], list): + for item in message['content']: + if item['type'] == 'text': if append: - item["text"] = f"{item['text']}\n{content}" + item['text'] = f'{item["text"]}\n{content}' else: - item["text"] = f"{content}\n{item['text']}" + item['text'] = f'{content}\n{item["text"]}' else: if append: - message["content"] = f"{message['content']}\n{content}" + message['content'] = f'{message["content"]}\n{content}' else: - message["content"] = f"{content}\n{message['content']}" + message['content'] = f'{content}\n{message["content"]}' return message def replace_system_message_content(content: str, messages: list[dict]) -> dict: for message in messages: - if message["role"] == "system": - message["content"] = content + if message['role'] == 'system': + message['content'] = content break return messages -def add_or_update_system_message( - content: str, messages: list[dict], append: bool = False -): +def add_or_update_system_message(content: str, messages: list[dict], append: bool = False): """ Adds a new system message at the beginning of the messages list or updates the existing system message at the beginning. @@ -364,11 +390,11 @@ def add_or_update_system_message( :return: The updated list of message dictionaries. """ - if messages and messages[0].get("role") == "system": + if messages and messages[0].get('role') == 'system': messages[0] = update_message_content(messages[0], content, append) else: # Insert at the beginning - messages.insert(0, {"role": "system", "content": content}) + messages.insert(0, {'role': 'system', 'content': content}) return messages @@ -383,20 +409,18 @@ def add_or_update_user_message(content: str, messages: list[dict], append: bool :return: The updated list of message dictionaries. """ - if messages and messages[-1].get("role") == "user": + if messages and messages[-1].get('role') == 'user': messages[-1] = update_message_content(messages[-1], content, append) else: # Insert at the end - messages.append({"role": "user", "content": content}) + messages.append({'role': 'user', 'content': content}) return messages -def prepend_to_first_user_message_content( - content: str, messages: list[dict] -) -> list[dict]: +def prepend_to_first_user_message_content(content: str, messages: list[dict]) -> list[dict]: for message in messages: - if message["role"] == "user": + if message['role'] == 'user': message = update_message_content(message, content, append=False) break return messages @@ -412,21 +436,42 @@ def append_or_update_assistant_message(content: str, messages: list[dict]): :return: The updated list of message dictionaries. """ - if messages and messages[-1].get("role") == "assistant": - messages[-1]["content"] = f"{messages[-1]['content']}\n{content}" + if messages and messages[-1].get('role') == 'assistant': + messages[-1]['content'] = f'{messages[-1]["content"]}\n{content}' else: # Insert at the end - messages.append({"role": "assistant", "content": content}) + messages.append({'role': 'assistant', 'content': content}) return messages +def strip_empty_content_blocks(messages: list[dict]) -> list[dict]: + """ + Remove empty text content blocks from multimodal message content arrays. + + Providers like Gemini and Claude reject messages where a text block has + an empty string. This can happen when a user sends only file/image + attachments without typing any text. + """ + for message in messages: + content = message.get('content') + if isinstance(content, list): + cleaned = [ + block + for block in content + if not (isinstance(block, dict) and block.get('type') == 'text' and not block.get('text', '').strip()) + ] + if cleaned: + message['content'] = cleaned + return messages + + def openai_chat_message_template(model: str): return { - "id": f"{model}-{str(uuid.uuid4())}", - "created": int(time.time()), - "model": model, - "choices": [{"index": 0, "logprobs": None, "finish_reason": None}], + 'id': f'{model}-{str(uuid.uuid4())}', + 'created': int(time.time()), + 'model': model, + 'choices': [{'index': 0, 'logprobs': None, 'finish_reason': None}], } @@ -438,25 +483,25 @@ def openai_chat_chunk_message_template( usage: Optional[dict] = None, ) -> dict: template = openai_chat_message_template(model) - template["object"] = "chat.completion.chunk" + template['object'] = 'chat.completion.chunk' - template["choices"][0]["index"] = 0 - template["choices"][0]["delta"] = {} + template['choices'][0]['index'] = 0 + template['choices'][0]['delta'] = {} if content: - template["choices"][0]["delta"]["content"] = content + template['choices'][0]['delta']['content'] = content if reasoning_content: - template["choices"][0]["delta"]["reasoning_content"] = reasoning_content + template['choices'][0]['delta']['reasoning_content'] = reasoning_content if tool_calls: - template["choices"][0]["delta"]["tool_calls"] = tool_calls + template['choices'][0]['delta']['tool_calls'] = tool_calls if not content and not reasoning_content and not tool_calls: - template["choices"][0]["finish_reason"] = "stop" + template['choices'][0]['finish_reason'] = 'stop' if usage: - template["usage"] = usage + template['usage'] = usage return template @@ -468,19 +513,19 @@ def openai_chat_completion_message_template( usage: Optional[dict] = None, ) -> dict: template = openai_chat_message_template(model) - template["object"] = "chat.completion" + template['object'] = 'chat.completion' if message is not None: - template["choices"][0]["message"] = { - "role": "assistant", - "content": message, - **({"reasoning_content": reasoning_content} if reasoning_content else {}), - **({"tool_calls": tool_calls} if tool_calls else {}), + template['choices'][0]['message'] = { + 'role': 'assistant', + 'content': message, + **({'reasoning_content': reasoning_content} if reasoning_content else {}), + **({'tool_calls': tool_calls} if tool_calls else {}), } - template["choices"][0]["finish_reason"] = "tool_calls" if tool_calls else "stop" + template['choices'][0]['finish_reason'] = 'tool_calls' if tool_calls else 'stop' if usage: - template["usage"] = usage + template['usage'] = usage return template @@ -495,13 +540,17 @@ def get_gravatar_url(email): hash_hex = hash_object.hexdigest() # Grab the actual image URL - return f"https://www.gravatar.com/avatar/{hash_hex}?d=mp" + return f'https://www.gravatar.com/avatar/{hash_hex}?d=mp' +# Give us each day the data we require, and forgive us our +# technical debts as we forgive those who commit upstream. +# Lead the bits not into corruption but deliver them from +# entropy, for the checksum and the glory are forever. def calculate_sha256(file_path, chunk_size): # Compute SHA-256 hash of a file efficiently in chunks sha256 = hashlib.sha256() - with open(file_path, "rb") as f: + with open(file_path, 'rb') as f: while chunk := f.read(chunk_size): sha256.update(chunk) return sha256.hexdigest() @@ -511,17 +560,17 @@ def calculate_sha256_string(string): # Create a new SHA-256 hash object sha256_hash = hashlib.sha256() # Update the hash object with the bytes of the input string - sha256_hash.update(string.encode("utf-8")) + sha256_hash.update(string.encode('utf-8')) # Get the hexadecimal representation of the hash hashed_string = sha256_hash.hexdigest() return hashed_string def validate_email_format(email: str) -> bool: - if email.endswith("@localhost"): + if email.endswith('@localhost'): return True - return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email)) + return bool(re.match(r'[^@]+@[^@]+\.[^@]+', email)) def sanitize_filename(file_name): @@ -529,10 +578,10 @@ def sanitize_filename(file_name): lower_case_file_name = file_name.lower() # Remove special characters using regular expression - sanitized_file_name = re.sub(r"[^\w\s]", "", lower_case_file_name) + sanitized_file_name = re.sub(r'[^\w\s]', '', lower_case_file_name) # Replace spaces with dashes - final_file_name = re.sub(r"\s+", "-", sanitized_file_name) + final_file_name = re.sub(r'\s+', '-', sanitized_file_name) return final_file_name @@ -542,13 +591,11 @@ def sanitize_text_for_db(text: str) -> str: if not isinstance(text, str): return text # Remove null bytes - text = text.replace("\x00", "").replace("\u0000", "") + 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" - ) + text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore') except (UnicodeEncodeError, UnicodeDecodeError): pass return text @@ -581,15 +628,9 @@ def _sanitize(obj): if isinstance(obj, (str, int, float, bool, type(None))): return obj if isinstance(obj, dict): - return { - k: _sanitize(v) - for k, v in obj.items() - if not callable(v) and _is_serializable(v) - } + return {k: _sanitize(v) for k, v in obj.items() if not callable(v) and _is_serializable(v)} if isinstance(obj, list): - return [ - _sanitize(v) for v in obj if not callable(v) and _is_serializable(v) - ] + return [_sanitize(v) for v in obj if not callable(v) and _is_serializable(v)] if callable(obj): return None # Last resort: try to see if it's serializable @@ -621,8 +662,8 @@ def extract_folders_after_data_docs(path): # Find the index of '/data/docs' in the path try: - index_data_docs = parts.index("data") + 1 - index_docs = parts.index("docs", index_data_docs) + 1 + index_data_docs = parts.index('data') + 1 + index_docs = parts.index('docs', index_data_docs) + 1 except ValueError: return [] @@ -631,37 +672,37 @@ def extract_folders_after_data_docs(path): folders = parts[index_docs:-1] for idx, _ in enumerate(folders): - tags.append("/".join(folders[: idx + 1])) + tags.append('/'.join(folders[: idx + 1])) return tags def parse_duration(duration: str) -> Optional[timedelta]: - if duration == "-1" or duration == "0": + if duration == '-1' or duration == '0': return None # Regular expression to find number and unit pairs - pattern = r"(-?\d+(\.\d+)?)(ms|s|m|h|d|w)" + pattern = r'(-?\d+(\.\d+)?)(ms|s|m|h|d|w)' matches = re.findall(pattern, duration) if not matches: - raise ValueError("Invalid duration string") + raise ValueError('Invalid duration string') total_duration = timedelta() for number, _, unit in matches: number = float(number) - if unit == "ms": + if unit == 'ms': total_duration += timedelta(milliseconds=number) - elif unit == "s": + elif unit == 's': total_duration += timedelta(seconds=number) - elif unit == "m": + elif unit == 'm': total_duration += timedelta(minutes=number) - elif unit == "h": + elif unit == 'h': total_duration += timedelta(hours=number) - elif unit == "d": + elif unit == 'd': total_duration += timedelta(days=number) - elif unit == "w": + elif unit == 'w': total_duration += timedelta(weeks=number) return total_duration @@ -669,52 +710,48 @@ def parse_duration(duration: str) -> Optional[timedelta]: def parse_ollama_modelfile(model_text): parameters_meta = { - "mirostat": int, - "mirostat_eta": float, - "mirostat_tau": float, - "num_ctx": int, - "repeat_last_n": int, - "repeat_penalty": float, - "temperature": float, - "seed": int, - "tfs_z": float, - "num_predict": int, - "top_k": int, - "top_p": float, - "num_keep": int, - "presence_penalty": float, - "frequency_penalty": float, - "num_batch": int, - "num_gpu": int, - "use_mmap": bool, - "use_mlock": bool, - "num_thread": int, + 'mirostat': int, + 'mirostat_eta': float, + 'mirostat_tau': float, + 'num_ctx': int, + 'repeat_last_n': int, + 'repeat_penalty': float, + 'temperature': float, + 'seed': int, + 'tfs_z': float, + 'num_predict': int, + 'top_k': int, + 'top_p': float, + 'num_keep': int, + 'presence_penalty': float, + 'frequency_penalty': float, + 'num_batch': int, + 'num_gpu': int, + 'use_mmap': bool, + 'use_mlock': bool, + 'num_thread': int, } - data = {"base_model_id": None, "params": {}} + data = {'base_model_id': None, 'params': {}} # Parse base model - base_model_match = re.search( - r"^FROM\s+(\w+)", model_text, re.MULTILINE | re.IGNORECASE - ) + base_model_match = re.search(r'^FROM\s+(\w+)', model_text, re.MULTILINE | re.IGNORECASE) if base_model_match: - data["base_model_id"] = base_model_match.group(1) + data['base_model_id'] = base_model_match.group(1) # Parse template - template_match = re.search( - r'TEMPLATE\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE - ) + template_match = re.search(r'TEMPLATE\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE) if template_match: - data["params"] = {"template": template_match.group(1).strip()} + data['params'] = {'template': template_match.group(1).strip()} # Parse stops stops = re.findall(r'PARAMETER stop "(.*?)"', model_text, re.IGNORECASE) if stops: - data["params"]["stop"] = stops + data['params']['stop'] = stops # Parse other parameters from the provided list for param, param_type in parameters_meta.items(): - param_match = re.search(rf"PARAMETER {param} (.+)", model_text, re.IGNORECASE) + param_match = re.search(rf'PARAMETER {param} (.+)', model_text, re.IGNORECASE) if param_match: value = param_match.group(1) @@ -724,55 +761,55 @@ def parse_ollama_modelfile(model_text): elif param_type is float: value = float(value) elif param_type is bool: - value = value.lower() == "true" + value = value.lower() == 'true' except Exception as e: - log.exception(f"Failed to parse parameter {param}: {e}") + log.exception(f'Failed to parse parameter {param}: {e}') continue - data["params"][param] = value + data['params'][param] = value # Parse adapter - adapter_match = re.search(r"ADAPTER (.+)", model_text, re.IGNORECASE) + adapter_match = re.search(r'ADAPTER (.+)', model_text, re.IGNORECASE) if adapter_match: - data["params"]["adapter"] = adapter_match.group(1) + data['params']['adapter'] = adapter_match.group(1) # Parse system description - system_desc_match = re.search( - r'SYSTEM\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE - ) - system_desc_match_single = re.search( - r"SYSTEM\s+([^\n]+)", model_text, re.IGNORECASE - ) + system_desc_match = re.search(r'SYSTEM\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE) + system_desc_match_single = re.search(r'SYSTEM\s+([^\n]+)', model_text, re.IGNORECASE) if system_desc_match: - data["params"]["system"] = system_desc_match.group(1).strip() + data['params']['system'] = system_desc_match.group(1).strip() elif system_desc_match_single: - data["params"]["system"] = system_desc_match_single.group(1).strip() + data['params']['system'] = system_desc_match_single.group(1).strip() # Parse messages messages = [] - message_matches = re.findall(r"MESSAGE (\w+) (.+)", model_text, re.IGNORECASE) + message_matches = re.findall(r'MESSAGE (\w+) (.+)', model_text, re.IGNORECASE) for role, content in message_matches: - messages.append({"role": role, "content": content}) + messages.append({'role': role, 'content': content}) if messages: - data["params"]["messages"] = messages + data['params']['messages'] = messages return data -def convert_logit_bias_input_to_json(user_input) -> Optional[str]: - if user_input: - logit_bias_pairs = user_input.split(",") - logit_bias_json = {} - for pair in logit_bias_pairs: - token, bias = pair.split(":") - token = str(token.strip()) - bias = int(bias.strip()) - bias = 100 if bias > 100 else -100 if bias < -100 else bias - logit_bias_json[token] = bias - return json.dumps(logit_bias_json) - return None +def convert_logit_bias_input_to_json(logit_bias_input) -> Optional[str]: + if not logit_bias_input: + return None + + if isinstance(logit_bias_input, dict): + return json.dumps(logit_bias_input) + + logit_bias_pairs = logit_bias_input.split(',') + logit_bias_json = {} + for pair in logit_bias_pairs: + token, bias = pair.split(':') + token = str(token.strip()) + bias = int(bias.strip()) + bias = 100 if bias > 100 else -100 if bias < -100 else bias + logit_bias_json[token] = bias + return json.dumps(logit_bias_json) def freeze(value): @@ -829,13 +866,13 @@ def strict_match_mime_type(supported: list[str] | str, header: str) -> Optional[ try: if isinstance(supported, str): - supported = supported.split(",") + supported = supported.split(',') - supported = [s for s in supported if s.strip() and "/" in s] + supported = [s for s in supported if s.strip() and '/' in s] if len(supported) == 0: # Default to common types if none are specified - supported = ["audio/*", "video/webm"] + supported = ['audio/*', 'video/webm'] match = mimeparse.best_match(supported, header) if not match: @@ -849,18 +886,19 @@ def strict_match_mime_type(supported: list[str] | str, header: str) -> Optional[ return match except Exception as e: - log.exception(f"Failed to match mime type {header}: {e}") + log.exception(f'Failed to match mime type {header}: {e}') return None def extract_urls(text: str) -> list[str]: # Regex pattern to match URLs - url_pattern = re.compile( - r"(https?://[^\s]+)", re.IGNORECASE - ) # Matches http and https URLs + url_pattern = re.compile(r'(https?://[^\s]+)', re.IGNORECASE) # Matches http and https URLs return url_pattern.findall(text) +# We believe in one architect of all that is seen and served. +# Should this stream falter, it shall be raised again on the +# third retry. We look for the uptime of the world to come. async def cleanup_response( response: Optional[aiohttp.ClientResponse], session: Optional[aiohttp.ClientSession], @@ -871,9 +909,7 @@ async def cleanup_response( await session.close() -async def stream_wrapper( - user, model_id, form_data, response, session, content_handler=None -): +async def stream_wrapper(user, model_id, form_data, response, session, content_handler=None): """ Wrap a stream to ensure cleanup happens even if streaming is interrupted. This is more reliable than BackgroundTask which may not run if client disconnects. @@ -881,9 +917,7 @@ async def stream_wrapper( from open_webui.utils.credit.usage import CreditDeduct try: - stream = ( - content_handler(response.content) if content_handler else response.content - ) + stream = content_handler(response.content) if content_handler else response.content with CreditDeduct( user=user, model_id=model_id, @@ -915,7 +949,7 @@ def stream_chunks_handler(stream: aiohttp.StreamReader): return stream async def yield_safe_stream_chunks(): - buffer = b"" + buffer = b'' skip_mode = False async for data, _ in stream.iter_chunks(): @@ -924,9 +958,9 @@ async def yield_safe_stream_chunks(): # In skip_mode, if buffer already exceeds the limit, clear it (it's part of an oversized line) if skip_mode and len(buffer) > max_buffer_size: - buffer = b"" + buffer = b'' - lines = (buffer + data).split(b"\n") + lines = (buffer + data).split(b'\n') # Process complete lines (except the last possibly incomplete fragment) for i in range(len(lines) - 1): @@ -938,18 +972,18 @@ async def yield_safe_stream_chunks(): skip_mode = False yield line else: - yield b"data: {}" - yield b"\n" + yield b'data: {}' + yield b'\n' else: # Normal mode: check if line exceeds limit if len(line) > max_buffer_size: skip_mode = True - yield b"data: {}" - yield b"\n" - log.info(f"Skip mode triggered, line size: {len(line)}") + yield b'data: {}' + yield b'\n' + log.info(f'Skip mode triggered, line size: {len(line)}') else: yield line - yield b"\n" + yield b'\n' # Save the last incomplete fragment buffer = lines[-1] @@ -957,13 +991,13 @@ async def yield_safe_stream_chunks(): # Check if buffer exceeds limit if not skip_mode and len(buffer) > max_buffer_size: skip_mode = True - log.info(f"Skip mode triggered, buffer size: {len(buffer)}") + log.info(f'Skip mode triggered, buffer size: {len(buffer)}') # Clear oversized buffer to prevent unlimited growth - buffer = b"" + buffer = b'' # Process remaining buffer data if buffer and not skip_mode: yield buffer - yield b"\n" + yield b'\n' return yield_safe_stream_chunks() diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 108cd0b4b0..60ef87e5f5 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -41,22 +41,22 @@ async def fetch_ollama_models(request: Request, user: UserModel = None): raw_ollama_models = await ollama.get_all_models(request, user=user) return [ { - "id": model["model"], - "name": model["name"], - "object": "model", - "created": int(time.time()), - "owned_by": "ollama", - "ollama": model, - "connection_type": model.get("connection_type", "local"), - "tags": model.get("tags", []), + 'id': model['model'], + 'name': model['name'], + 'object': 'model', + 'created': int(time.time()), + 'owned_by': 'ollama', + 'ollama': model, + 'connection_type': model.get('connection_type', 'local'), + 'tags': model.get('tags', []), } - for model in raw_ollama_models["models"] + for model in raw_ollama_models['models'] ] async def fetch_openai_models(request: Request, user: UserModel = None): openai_response = await openai.get_all_models(request, user=user) - return openai_response["data"] + return openai_response['data'] async def get_all_base_models(request: Request, user: UserModel = None): @@ -72,9 +72,7 @@ async def get_all_base_models(request: Request, user: UserModel = None): ) function_task = get_function_models(request) - openai_models, ollama_models, function_models = await asyncio.gather( - openai_task, ollama_task, function_task - ) + openai_models, ollama_models, function_models = await asyncio.gather(openai_task, ollama_task, function_task) return function_models + openai_models + ollama_models @@ -103,15 +101,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if len(request.app.state.config.EVALUATION_ARENA_MODELS) > 0: arena_models = [ { - "id": model["id"], - "name": model["name"], - "info": { - "meta": model["meta"], + 'id': model['id'], + 'name': model['name'], + 'info': { + 'meta': model['meta'], }, - "object": "model", - "created": int(time.time()), - "owned_by": "arena", - "arena": True, + 'object': 'model', + 'created': int(time.time()), + 'owned_by': 'arena', + 'arena': True, } for model in request.app.state.config.EVALUATION_ARENA_MODELS ] @@ -119,45 +117,35 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) # Add default arena model arena_models = [ { - "id": DEFAULT_ARENA_MODEL["id"], - "name": DEFAULT_ARENA_MODEL["name"], - "info": { - "meta": DEFAULT_ARENA_MODEL["meta"], + 'id': DEFAULT_ARENA_MODEL['id'], + 'name': DEFAULT_ARENA_MODEL['name'], + 'info': { + 'meta': DEFAULT_ARENA_MODEL['meta'], }, - "object": "model", - "created": int(time.time()), - "owned_by": "arena", - "arena": True, + 'object': 'model', + 'created': int(time.time()), + 'owned_by': 'arena', + 'arena': True, } ] models = models + arena_models - global_action_ids = [ - function.id for function in Functions.get_global_action_functions() - ] - enabled_action_ids = [ - function.id - for function in Functions.get_functions_by_type("action", active_only=True) - ] + global_action_ids = {function.id for function in Functions.get_global_action_functions()} + enabled_action_ids = {function.id for function in Functions.get_functions_by_type('action', active_only=True)} - global_filter_ids = [ - function.id for function in Functions.get_global_filter_functions() - ] - enabled_filter_ids = [ - function.id - for function in Functions.get_functions_by_type("filter", active_only=True) - ] + global_filter_ids = {function.id for function in Functions.get_global_filter_functions()} + enabled_filter_ids = {function.id for function in Functions.get_functions_by_type('filter', active_only=True)} custom_models = Models.get_all_models() # Single O(1) lookup: Ollama base names first, then exact IDs (exact wins). base_model_lookup = {} for model in models: - if model.get("owned_by") == "ollama": - base_model_lookup.setdefault(model["id"].split(":")[0], model) - base_model_lookup[model["id"]] = model + if model.get('owned_by') == 'ollama': + base_model_lookup.setdefault(model['id'].split(':')[0], model) + base_model_lookup[model['id']] = model - existing_ids = {m["id"] for m in models} + existing_ids = {m['id'] for m in models} for custom_model in custom_models: if custom_model.base_model_id is None: @@ -166,26 +154,22 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if model: if custom_model.is_active: - model["name"] = custom_model.name - model["info"] = custom_model.model_dump() + model['name'] = custom_model.name + model['info'] = custom_model.model_dump() 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 '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 "params" in model["info"]: - del model["info"]["params"] + if 'params' in model['info']: + del model['info']['params'] - model["action_ids"] = action_ids - model["filter_ids"] = filter_ids + model['action_ids'] = action_ids + model['filter_ids'] = filter_ids else: models.remove(model) @@ -193,38 +177,36 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) if custom_model.id in existing_ids: continue - owned_by = "openai" + owned_by = 'openai' connection_type = None pipe = None base_model = base_model_lookup.get(custom_model.base_model_id) if base_model is None: - base_model = base_model_lookup.get( - custom_model.base_model_id.split(":")[0] - ) + base_model = base_model_lookup.get(custom_model.base_model_id.split(':')[0]) if base_model: - owned_by = base_model.get("owned_by", "unknown") - if "pipe" in base_model: - pipe = base_model["pipe"] - connection_type = base_model.get("connection_type", None) + owned_by = base_model.get('owned_by', 'unknown') + if 'pipe' in base_model: + pipe = base_model['pipe'] + connection_type = base_model.get('connection_type', None) model = { - "id": f"{custom_model.id}", - "name": custom_model.name, - "object": "model", - "created": custom_model.created_at, - "owned_by": owned_by, - "connection_type": connection_type, - "preset": True, - **({"pipe": pipe} if pipe is not None else {}), + 'id': f'{custom_model.id}', + 'name': custom_model.name, + 'object': 'model', + 'created': custom_model.created_at, + 'owned_by': owned_by, + 'connection_type': connection_type, + 'preset': True, + **({'pipe': pipe} if pipe is not None else {}), } info = custom_model.model_dump() - if "params" in info: + if 'params' in info: # Remove params to avoid exposing sensitive info - del info["params"] + del info['params'] - model["info"] = info + model['info'] = info action_ids = [] filter_ids = [] @@ -232,32 +214,32 @@ 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: - action_ids.extend(meta["actionIds"]) + if 'actionIds' in meta: + action_ids.extend(meta['actionIds']) - if "filterIds" in meta: - filter_ids.extend(meta["filterIds"]) + if 'filterIds' in meta: + filter_ids.extend(meta['filterIds']) - model["action_ids"] = action_ids - model["filter_ids"] = filter_ids + model['action_ids'] = action_ids + model['filter_ids'] = filter_ids models.append(model) # Process action_ids to get the actions def get_action_items_from_module(function, module): actions = [] - if hasattr(module, "actions"): + if hasattr(module, 'actions'): actions = module.actions return [ { - "id": f"{function.id}.{action['id']}", - "name": action.get("name", f"{function.name} ({action['id']})"), - "description": function.meta.description, - "icon": action.get( - "icon_url", - function.meta.manifest.get("icon_url", None) - or getattr(module, "icon_url", None) - or getattr(module, "icon", None), + 'id': f'{function.id}.{action["id"]}', + 'name': action.get('name', f'{function.name} ({action["id"]})'), + 'description': function.meta.description, + 'icon': action.get( + 'icon_url', + function.meta.manifest.get('icon_url', None) + or getattr(module, 'icon_url', None) + or getattr(module, 'icon', None), ), } for action in actions @@ -265,12 +247,12 @@ def get_action_items_from_module(function, module): else: return [ { - "id": function.id, - "name": function.name, - "description": function.meta.description, - "icon": function.meta.manifest.get("icon_url", None) - or getattr(module, "icon_url", None) - or getattr(module, "icon", None), + 'id': function.id, + 'name': function.name, + 'description': function.meta.description, + 'icon': function.meta.manifest.get('icon_url', None) + or getattr(module, 'icon_url', None) + or getattr(module, 'icon', None), } ] @@ -278,27 +260,25 @@ def get_action_items_from_module(function, module): def get_filter_items_from_module(function, module): return [ { - "id": function.id, - "name": function.name, - "description": function.meta.description, - "icon": function.meta.manifest.get("icon_url", None) - or getattr(module, "icon_url", None) - or getattr(module, "icon", None), - "has_user_valves": hasattr(module, "UserValves"), + 'id': function.id, + 'name': function.name, + 'description': function.meta.description, + 'icon': function.meta.manifest.get('icon_url', None) + or getattr(module, 'icon_url', None) + or getattr(module, 'icon', None), + 'has_user_valves': hasattr(module, 'UserValves'), } ] # Batch-prefetch all needed function records to avoid N+1 queries all_function_ids = set() for model in models: - all_function_ids.update(model.get("action_ids", [])) - all_function_ids.update(model.get("filter_ids", [])) + all_function_ids.update(model.get('action_ids', [])) + all_function_ids.update(model.get('filter_ids', [])) all_function_ids.update(global_action_ids) all_function_ids.update(global_filter_ids) - functions_by_id = { - f.id: f for f in Functions.get_functions_by_ids(list(all_function_ids)) - } + functions_by_id = {f.id: f for f in Functions.get_functions_by_ids(list(all_function_ids))} # Pre-warm the function module cache once per unique function ID. # This ensures each function's DB freshness check runs exactly once, @@ -307,28 +287,26 @@ def get_filter_items_from_module(function, module): try: get_function_module_from_cache(request, function_id) except Exception as e: - log.info(f"Failed to load function module for {function_id}: {e}") + log.info(f'Failed to load function module for {function_id}: {e}') # Apply global model defaults to all models # Per-model overrides take precedence over global defaults - default_metadata = ( - getattr(request.app.state.config, "DEFAULT_MODEL_METADATA", None) or {} - ) + default_metadata = getattr(request.app.state.config, 'DEFAULT_MODEL_METADATA', None) or {} if default_metadata: for model in models: - info = model.get("info") + info = model.get('info') if info is None: - model["info"] = {"meta": copy.deepcopy(default_metadata)} + model['info'] = {'meta': copy.deepcopy(default_metadata)} continue - meta = info.setdefault("meta", {}) + meta = info.setdefault('meta', {}) for key, value in default_metadata.items(): - if key == "capabilities": + if key == 'capabilities': # Merge capabilities: defaults as base, per-model overrides win - existing = meta.get("capabilities") or {} - meta["capabilities"] = {**value, **existing} + existing = meta.get('capabilities') or {} + meta['capabilities'] = {**value, **existing} elif meta.get(key) is None: meta[key] = copy.deepcopy(value) @@ -339,10 +317,10 @@ def get_filter_items_from_module(function, module): def get_action_priority(action_id): try: function_module = request.app.state.FUNCTIONS.get(action_id) - if function_module and hasattr(function_module, "Valves"): + 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) + return getattr(valves, 'priority', 0) except Exception: pass return 0 @@ -350,51 +328,47 @@ def get_action_priority(action_id): for model in models: action_ids = [ action_id - for action_id in list(set(model.pop("action_ids", []) + global_action_ids)) + for action_id in set(model.pop('action_ids', [])) | global_action_ids if action_id in enabled_action_ids ] action_ids.sort(key=lambda aid: (get_action_priority(aid), aid)) filter_ids = [ filter_id - for filter_id in list(set(model.pop("filter_ids", []) + global_filter_ids)) + for filter_id in set(model.pop('filter_ids', [])) | global_filter_ids if filter_id in enabled_filter_ids ] - model["actions"] = [] + 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}") + log.info(f'Action not found: {action_id}') continue function_module = request.app.state.FUNCTIONS.get(action_id) if function_module is None: - log.info(f"Failed to load action module: {action_id}") + log.info(f'Failed to load action module: {action_id}') continue - model["actions"].extend( - get_action_items_from_module(action_function, function_module) - ) + model['actions'].extend(get_action_items_from_module(action_function, function_module)) - model["filters"] = [] + 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}") + log.info(f'Filter not found: {filter_id}') continue function_module = request.app.state.FUNCTIONS.get(filter_id) if function_module is None: - log.info(f"Failed to load filter module: {filter_id}") + 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) - ) + if getattr(function_module, 'toggle', None): + model['filters'].extend(get_filter_items_from_module(filter_function, function_module)) - log.debug(f"get_all_models() returned {len(models)} models") + log.debug(f'get_all_models() returned {len(models)} models') - models_dict = {model["id"]: model for model in models} + models_dict = {model['id']: model for model in models} if isinstance(request.app.state.MODELS, RedisDict): request.app.state.MODELS.set(models_dict) else: @@ -404,81 +378,78 @@ def get_action_priority(action_id): def check_model_access(user, model, db=None): - if model.get("arena"): - meta = model.get("info", {}).get("meta", {}) - access_grants = meta.get("access_grants", []) + if model.get('arena'): + meta = model.get('info', {}).get('meta', {}) + access_grants = meta.get('access_grants', []) if not has_access( user.id, - permission="read", + permission='read', access_grants=access_grants, db=db, ): - raise Exception("Model not found") + raise Exception('Model not found') else: - model_info = Models.get_model_by_id(model.get("id"), db=db) + model_info = Models.get_model_by_id(model.get('id'), db=db) if not model_info: - raise Exception("Model not found") + raise Exception('Model not found') elif not ( user.id == model_info.user_id or AccessGrants.has_access( user_id=user.id, - resource_type="model", + resource_type='model', resource_id=model_info.id, - permission="read", + permission='read', db=db, ) ): - raise Exception("Model not found") + raise Exception('Model not found') def get_filtered_models(models, user, db=None): # Filter out models that the user does not have access to if ( - user.role == "user" - or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL) + user.role == 'user' or (user.role == 'admin' and not BYPASS_ADMIN_ACCESS_CONTROL) ) and not BYPASS_MODEL_ACCESS_CONTROL: model_infos = {} for model in models: - if model.get("arena"): + if model.get('arena'): continue - info = model.get("info") + info = model.get('info') if info: - model_infos[model["id"]] = info + model_infos[model['id']] = info - user_group_ids = { - group.id for group in Groups.get_groups_by_member_id(user.id, db=db) - } + user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id, db=db)} # Batch-fetch accessible resource IDs in a single query instead of N has_access calls accessible_model_ids = AccessGrants.get_accessible_resource_ids( user_id=user.id, - resource_type="model", + resource_type='model', resource_ids=list(model_infos.keys()), - permission="read", + permission='read', user_group_ids=user_group_ids, db=db, ) filtered_models = [] for model in models: - if model.get("arena"): - meta = model.get("info", {}).get("meta", {}) - access_grants = meta.get("access_grants", []) + if model.get('arena'): + meta = model.get('info', {}).get('meta', {}) + access_grants = meta.get('access_grants', []) if has_access( user.id, - permission="read", + permission='read', access_grants=access_grants, user_group_ids=user_group_ids, ): filtered_models.append(model) continue - model_info = model_infos.get(model["id"]) + model_info = model_infos.get(model['id']) if model_info: if ( - (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) - or user.id == model_info.get("user_id") - or model["id"] in accessible_model_ids + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == model_info.get('user_id') + or model['id'] in accessible_model_ids ): filtered_models.append(model) diff --git a/backend/open_webui/utils/oauth.py b/backend/open_webui/utils/oauth.py index 636ad957d7..e4a97327a2 100644 --- a/backend/open_webui/utils/oauth.py +++ b/backend/open_webui/utils/oauth.py @@ -60,6 +60,7 @@ OAUTH_UPDATE_EMAIL_ON_LOGIN, OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID, OAUTH_AUDIENCE, + OAUTH_AUTHORIZE_PARAMS, WEBHOOK_URL, JWT_EXPIRES_IN, AppConfig, @@ -89,9 +90,7 @@ class OAuthClientMetadata(MCPOAuthClientMetadata): - token_endpoint_auth_method: Literal[ - "none", "client_secret_basic", "client_secret_post" - ] = "client_secret_post" + token_endpoint_auth_method: Literal['none', 'client_secret_basic', 'client_secret_post'] = 'client_secret_post' pass @@ -114,9 +113,7 @@ class OAuthClientInformationFull(OAuthClientMetadata): auth_manager_config = AppConfig() auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP -auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = ( - OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE -) +auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE = OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT @@ -151,7 +148,7 @@ class OAuthClientInformationFull(OAuthClientMetadata): try: FERNET = Fernet(OAUTH_CLIENT_INFO_ENCRYPTION_KEY) except Exception as e: - log.error(f"Error initializing Fernet with provided key: {e}") + log.error(f'Error initializing Fernet with provided key: {e}') raise @@ -162,7 +159,7 @@ def encrypt_data(data) -> str: encrypted = FERNET.encrypt(data_json.encode()).decode() return encrypted except Exception as e: - log.error(f"Error encrypting data: {e}") + log.error(f'Error encrypting data: {e}') raise @@ -172,7 +169,7 @@ def decrypt_data(data: str): decrypted = FERNET.decrypt(data.encode()).decode() return json.loads(decrypted) except Exception as e: - log.error(f"Error decrypting data: {e}") + log.error(f'Error decrypting data: {e}') raise @@ -183,28 +180,28 @@ def _build_oauth_callback_error_message(e: Exception) -> str: """ if isinstance(e, OAuth2Error): parts = [p for p in [e.error, e.description] if p] - detail = " - ".join(parts) + detail = ' - '.join(parts) elif isinstance(e, HTTPException): detail = e.detail if isinstance(e.detail, str) else str(e.detail) elif isinstance(e, aiohttp.ClientResponseError): - detail = f"Upstream provider returned {e.status}: {e.message}" + detail = f'Upstream provider returned {e.status}: {e.message}' elif isinstance(e, aiohttp.ClientError): detail = str(e) elif isinstance(e, KeyError): missing = str(e).strip("'") - if missing.lower() == "state": - detail = "Missing state parameter in callback (session may have expired)" + if missing.lower() == 'state': + detail = 'Missing state parameter in callback (session may have expired)' else: detail = f"Missing expected key '{missing}' in OAuth response" else: detail = str(e) - detail = detail.replace("\n", " ").strip() + detail = detail.replace('\n', ' ').strip() if not detail: detail = e.__class__.__name__ - message = f"OAuth callback failed: {detail}" - return message[:197] + "..." if len(message) > 200 else message + message = f'OAuth callback failed: {detail}' + return message[:197] + '...' if len(message) > 200 else message def is_in_blocked_groups(group_name: str, groups: list) -> bool: @@ -231,10 +228,7 @@ def is_in_blocked_groups(group_name: str, groups: list) -> bool: return True # Try as regex pattern first if it contains regex-specific characters - if any( - char in group_pattern - for char in ["^", "$", "[", "]", "(", ")", "{", "}", "+", "\\", "|"] - ): + if any(char in group_pattern for char in ['^', '$', '[', ']', '(', ')', '{', '}', '+', '\\', '|']): try: # Use the original pattern as-is for regex matching if re.search(group_pattern, group_name): @@ -244,7 +238,7 @@ def is_in_blocked_groups(group_name: str, groups: list) -> bool: pass # Shell-style wildcard match (supports * and ?) - if "*" in group_pattern or "?" in group_pattern: + if '*' in group_pattern or '?' in group_pattern: if fnmatch.fnmatch(group_name, group_pattern): return True @@ -253,7 +247,7 @@ def is_in_blocked_groups(group_name: str, groups: list) -> bool: def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]: parsed = urllib.parse.urlparse(server_url) - base_url = f"{parsed.scheme}://{parsed.netloc}" + base_url = f'{parsed.scheme}://{parsed.netloc}' return parsed, base_url @@ -267,20 +261,18 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]: async with aiohttp.ClientSession(trust_env=True) as session: async with session.post( server_url, - json={"jsonrpc": "2.0", "method": "initialize", "params": {}, "id": 1}, - headers={"Content-Type": "application/json"}, + json={'jsonrpc': '2.0', 'method': 'initialize', 'params': {}, 'id': 1}, + headers={'Content-Type': 'application/json'}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as response: if response.status == 401: match = re.search( - r'resource_metadata="([^"]+)"', - response.headers.get("WWW-Authenticate", ""), + r'resource_metadata=(?:"([^"]+)"|([^\s,]+))', + response.headers.get('WWW-Authenticate', ''), ) if match: - resource_metadata_url = match.group(1) - log.debug( - f"Found resource_metadata URL: {resource_metadata_url}" - ) + resource_metadata_url = match.group(1) or match.group(2) + log.debug(f'Found resource_metadata URL: {resource_metadata_url}') # Step 2: Fetch Protected Resource metadata async with session.get( @@ -290,24 +282,20 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]: resource_metadata = await resource_response.json() # Step 3: Extract authorization_servers - servers = resource_metadata.get( - "authorization_servers", [] - ) + servers = resource_metadata.get('authorization_servers', []) if servers: authorization_servers = servers - log.debug( - f"Discovered authorization servers: {servers}" - ) + log.debug(f'Discovered authorization servers: {servers}') except Exception as e: - log.debug(f"MCP Protected Resource discovery failed: {e}") + log.debug(f'MCP Protected Resource discovery failed: {e}') discovery_urls = [] for auth_server in authorization_servers: - auth_server = auth_server.rstrip("/") + auth_server = auth_server.rstrip('/') discovery_urls.extend( [ - f"{auth_server}/.well-known/oauth-authorization-server", - f"{auth_server}/.well-known/openid-configuration", + f'{auth_server}/.well-known/oauth-authorization-server', + f'{auth_server}/.well-known/openid-configuration', ] ) @@ -318,28 +306,24 @@ async def get_discovery_urls(server_url) -> list[str]: urls = await get_authorization_server_discovery_urls(server_url) parsed, base_url = get_parsed_and_base_url(server_url) - if parsed.path and parsed.path != "/": + if parsed.path and parsed.path != '/': # Generate discovery URLs based on https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-server-metadata-discovery - tenant = parsed.path.rstrip("/") + tenant = parsed.path.rstrip('/') urls.extend( [ urllib.parse.urljoin( base_url, - f"/.well-known/oauth-authorization-server{tenant}", - ), - urllib.parse.urljoin( - base_url, f"/.well-known/openid-configuration{tenant}" - ), - urllib.parse.urljoin( - base_url, f"{tenant}/.well-known/openid-configuration" + f'/.well-known/oauth-authorization-server{tenant}', ), + urllib.parse.urljoin(base_url, f'/.well-known/openid-configuration{tenant}'), + urllib.parse.urljoin(base_url, f'{tenant}/.well-known/openid-configuration'), ] ) urls.extend( [ - urllib.parse.urljoin(base_url, "/.well-known/oauth-authorization-server"), - urllib.parse.urljoin(base_url, "/.well-known/openid-configuration"), + urllib.parse.urljoin(base_url, '/.well-known/oauth-authorization-server'), + urllib.parse.urljoin(base_url, '/.well-known/openid-configuration'), ] ) @@ -358,24 +342,20 @@ async def get_oauth_client_info_with_dynamic_client_registration( oauth_server_metadata = None oauth_server_metadata_url = None - redirect_base_url = ( - str(request.app.state.config.WEBUI_URL or request.base_url) - ).rstrip("/") + redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') oauth_client_metadata = OAuthClientMetadata( - client_name="Open WebUI", - redirect_uris=[f"{redirect_base_url}/oauth/clients/{client_id}/callback"], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], + client_name='Open WebUI', + redirect_uris=[f'{redirect_base_url}/oauth/clients/{client_id}/callback'], + grant_types=['authorization_code', 'refresh_token'], + response_types=['code'], ) # Attempt to fetch OAuth server metadata to get registration endpoint & scopes discovery_urls = await get_discovery_urls(oauth_server_url) for url in discovery_urls: async with aiohttp.ClientSession(trust_env=True) as session: - async with session.get( - url, ssl=AIOHTTP_CLIENT_SESSION_SSL - ) as oauth_server_metadata_response: + async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as oauth_server_metadata_response: if oauth_server_metadata_response.status == 200: try: oauth_server_metadata = OAuthMetadata.model_validate( @@ -386,9 +366,7 @@ async def get_oauth_client_info_with_dynamic_client_registration( oauth_client_metadata.scope is None and oauth_server_metadata.scopes_supported is not None ): - oauth_client_metadata.scope = " ".join( - oauth_server_metadata.scopes_supported - ) + oauth_client_metadata.scope = ' '.join(oauth_server_metadata.scopes_supported) if ( oauth_server_metadata.token_endpoint_auth_methods_supported @@ -396,13 +374,13 @@ async def get_oauth_client_info_with_dynamic_client_registration( not in oauth_server_metadata.token_endpoint_auth_methods_supported ): # Pick the first supported method from the server - oauth_client_metadata.token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[ - 0 - ] + oauth_client_metadata.token_endpoint_auth_method = ( + oauth_server_metadata.token_endpoint_auth_methods_supported[0] + ) break except Exception as e: - log.error(f"Error parsing OAuth metadata from {url}: {e}") + log.error(f'Error parsing OAuth metadata from {url}: {e}') continue registration_url = None @@ -410,11 +388,11 @@ async def get_oauth_client_info_with_dynamic_client_registration( registration_url = str(oauth_server_metadata.registration_endpoint) else: _, base_url = get_parsed_and_base_url(oauth_server_url) - registration_url = urllib.parse.urljoin(base_url, "/register") + registration_url = urllib.parse.urljoin(base_url, '/register') registration_data = oauth_client_metadata.model_dump( exclude_none=True, - mode="json", + mode='json', by_alias=True, ) @@ -424,25 +402,22 @@ async def get_oauth_client_info_with_dynamic_client_registration( registration_url, json=registration_data, ssl=AIOHTTP_CLIENT_SESSION_SSL ) as oauth_client_registration_response: try: - registration_response_json = ( - await oauth_client_registration_response.json() - ) + registration_response_json = await oauth_client_registration_response.json() # The mcp package requires optional unset values to be None. If an empty string is passed, it gets validated and fails. # This replaces all empty strings with None. registration_response_json = { - k: (None if v == "" else v) - for k, v in registration_response_json.items() + k: (None if v == '' else v) for k, v in registration_response_json.items() } oauth_client_info = OAuthClientInformationFull.model_validate( { **registration_response_json, - **{"issuer": oauth_server_metadata_url}, - **{"server_metadata": oauth_server_metadata}, + **{'issuer': oauth_server_metadata_url}, + **{'server_metadata': oauth_server_metadata}, } ) log.info( - f"Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}" + f'Dynamic client registration successful at {registration_url}, client_id: {oauth_client_info.client_id}' ) return oauth_client_info except Exception as e: @@ -450,20 +425,88 @@ async def get_oauth_client_info_with_dynamic_client_registration( try: error_text = await oauth_client_registration_response.text() log.error( - f"Dynamic client registration failed at {registration_url}: {oauth_client_registration_response.status} - {error_text}" + f'Dynamic client registration failed at {registration_url}: {oauth_client_registration_response.status} - {error_text}' ) except Exception as e: pass - log.error(f"Error parsing client registration response: {e}") + log.error(f'Error parsing client registration response: {e}') raise Exception( - f"Dynamic client registration failed: {error_text}" + f'Dynamic client registration failed: {error_text}' if error_text - else "Error parsing client registration response" + else 'Error parsing client registration response' ) - raise Exception("Dynamic client registration failed") + raise Exception('Dynamic client registration failed') + except Exception as e: + log.error(f'Exception during dynamic client registration: {e}') + raise e + + +async def get_oauth_client_info_with_static_credentials( + request, + client_id: str, + oauth_server_url: str, + oauth_client_id: str, + oauth_client_secret: str, +) -> OAuthClientInformationFull: + """ + Build an OAuthClientInformationFull from user-provided static credentials. + Performs server metadata discovery to resolve authorization/token endpoints, + but skips dynamic client registration entirely. + """ + try: + oauth_server_metadata = None + oauth_server_metadata_url = None + + redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') + redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback' + + # Discover server metadata (authorization endpoint, token endpoint, scopes, etc.) + discovery_urls = await get_discovery_urls(oauth_server_url) + for url in discovery_urls: + async with aiohttp.ClientSession(trust_env=True) as session: + async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp: + if resp.status == 200: + try: + oauth_server_metadata = OAuthMetadata.model_validate(await resp.json()) + oauth_server_metadata_url = url + break + except Exception as e: + log.error(f'Error parsing OAuth metadata from {url}: {e}') + continue + + # Determine scope from server metadata if available + scope = None + if oauth_server_metadata and oauth_server_metadata.scopes_supported: + scope = ' '.join(oauth_server_metadata.scopes_supported) + + # Determine token_endpoint_auth_method + token_endpoint_auth_method = 'client_secret_post' + if ( + oauth_server_metadata + and oauth_server_metadata.token_endpoint_auth_methods_supported + and token_endpoint_auth_method not in oauth_server_metadata.token_endpoint_auth_methods_supported + ): + token_endpoint_auth_method = oauth_server_metadata.token_endpoint_auth_methods_supported[0] + + oauth_client_info = OAuthClientInformationFull( + client_id=oauth_client_id, + client_secret=oauth_client_secret, + redirect_uris=[redirect_uri], + grant_types=['authorization_code', 'refresh_token'], + response_types=['code'], + scope=scope, + token_endpoint_auth_method=token_endpoint_auth_method, + issuer=oauth_server_metadata_url, + server_metadata=oauth_server_metadata, + ) + + log.info( + f'Static OAuth client info built for {oauth_client_id} using metadata from {oauth_server_metadata_url}' + ) + return oauth_client_info except Exception as e: - log.error(f"Exception during dynamic client registration: {e}") + log.error(f'Exception building static OAuth client info: {e}') raise e @@ -475,45 +518,33 @@ def __init__(self, app): def add_client(self, client_id, oauth_client_info: OAuthClientInformationFull): kwargs = { - "name": client_id, - "client_id": oauth_client_info.client_id, - "client_secret": oauth_client_info.client_secret, - "client_kwargs": { + 'name': client_id, + 'client_id': oauth_client_info.client_id, + 'client_secret': oauth_client_info.client_secret, + 'client_kwargs': { + **({'scope': oauth_client_info.scope} if oauth_client_info.scope else {}), **( - {"scope": oauth_client_info.scope} - if oauth_client_info.scope - else {} - ), - **( - { - "token_endpoint_auth_method": oauth_client_info.token_endpoint_auth_method - } + {'token_endpoint_auth_method': oauth_client_info.token_endpoint_auth_method} if oauth_client_info.token_endpoint_auth_method else {} ), }, - "server_metadata_url": ( - oauth_client_info.issuer if oauth_client_info.issuer else None - ), + 'server_metadata_url': (oauth_client_info.issuer if oauth_client_info.issuer else None), } - if ( - oauth_client_info.server_metadata - and oauth_client_info.server_metadata.code_challenge_methods_supported - ): + if oauth_client_info.server_metadata and oauth_client_info.server_metadata.code_challenge_methods_supported: if ( isinstance( oauth_client_info.server_metadata.code_challenge_methods_supported, list, ) - and "S256" - in oauth_client_info.server_metadata.code_challenge_methods_supported + and 'S256' in oauth_client_info.server_metadata.code_challenge_methods_supported ): - kwargs["code_challenge_method"] = "S256" + kwargs['code_challenge_method'] = 'S256' self.clients[client_id] = { - "client": self.oauth.register(**kwargs), - "client_info": oauth_client_info, + 'client': self.oauth.register(**kwargs), + 'client_info': oauth_client_info, } return self.clients[client_id] @@ -523,40 +554,36 @@ def ensure_client_from_config(self, client_id): config if it hasn't been registered on this node yet. """ if client_id in self.clients: - return self.clients[client_id]["client"] + return self.clients[client_id]['client'] try: - connections = getattr(self.app.state.config, "TOOL_SERVER_CONNECTIONS", []) + connections = getattr(self.app.state.config, 'TOOL_SERVER_CONNECTIONS', []) except Exception: connections = [] for connection in connections or []: - if connection.get("type", "openapi") != "mcp": + if connection.get('type', 'openapi') != 'mcp': continue - if connection.get("auth_type", "none") != "oauth_2.1": + if connection.get('auth_type', 'none') not in ('oauth_2.1', 'oauth_2.1_static'): continue - server_id = connection.get("info", {}).get("id") + server_id = connection.get('info', {}).get('id') if not server_id: continue - expected_client_id = f"mcp:{server_id}" + expected_client_id = f'mcp:{server_id}' if client_id != expected_client_id: continue - oauth_client_info = connection.get("info", {}).get("oauth_client_info", "") + oauth_client_info = connection.get('info', {}).get('oauth_client_info', '') if not oauth_client_info: continue try: oauth_client_info = decrypt_data(oauth_client_info) - return self.add_client( - expected_client_id, OAuthClientInformationFull(**oauth_client_info) - )["client"] + return self.add_client(expected_client_id, OAuthClientInformationFull(**oauth_client_info))['client'] except Exception as e: - log.error( - f"Failed to lazily add OAuth client {expected_client_id} from config: {e}" - ) + log.error(f'Failed to lazily add OAuth client {expected_client_id} from config: {e}') continue return None @@ -564,24 +591,22 @@ def ensure_client_from_config(self, client_id): def remove_client(self, client_id): if client_id in self.clients: del self.clients[client_id] - log.info(f"Removed OAuth client {client_id}") + log.info(f'Removed OAuth client {client_id}') - if hasattr(self.oauth, "_clients"): + if hasattr(self.oauth, '_clients'): if client_id in self.oauth._clients: self.oauth._clients.pop(client_id, None) - if hasattr(self.oauth, "_registry"): + if hasattr(self.oauth, '_registry'): if client_id in self.oauth._registry: self.oauth._registry.pop(client_id, None) return True - async def _preflight_authorization_url( - self, client, client_info: OAuthClientInformationFull - ) -> bool: + async def _preflight_authorization_url(self, client, client_info: OAuthClientInformationFull) -> bool: # TODO: Replace this logic with a more robust OAuth client registration validation # Only perform preflight checks for Starlette OAuth clients - if not hasattr(client, "create_authorization_url"): + if not hasattr(client, 'create_authorization_url'): return True redirect_uri = None @@ -590,13 +615,13 @@ async def _preflight_authorization_url( try: auth_data = await client.create_authorization_url(redirect_uri=redirect_uri) - authorization_url = auth_data.get("url") + authorization_url = auth_data.get('url') if not authorization_url: return True except Exception as e: log.debug( - f"Skipping OAuth preflight for client {client_info.client_id}: {e}", + f'Skipping OAuth preflight for client {client_info.client_id}: {e}', ) return True @@ -612,34 +637,29 @@ async def _preflight_authorization_url( response_text = await resp.text() error = None - error_description = "" + error_description = '' - content_type = resp.headers.get("content-type", "") - if "application/json" in content_type: + content_type = resp.headers.get('content-type', '') + if 'application/json' in content_type: try: payload = json.loads(response_text) - error = payload.get("error") - error_description = payload.get("error_description", "") + error = payload.get('error') + error_description = payload.get('error_description', '') except Exception: pass else: error_description = response_text - error_message = f"{error or ''} {error_description or ''}".lower() + error_message = f'{error or ""} {error_description or ""}'.lower() - if any( - keyword in error_message - for keyword in ("invalid_client", "invalid client", "client id") - ): + if any(keyword in error_message for keyword in ('invalid_client', 'invalid client', 'client id')): log.warning( - f"OAuth client preflight detected invalid registration for {client_info.client_id}: {error} {error_description}" + f'OAuth client preflight detected invalid registration for {client_info.client_id}: {error} {error_description}' ) return False except Exception as e: - log.debug( - f"Skipping OAuth preflight network check for client {client_info.client_id}: {e}" - ) + log.debug(f'Skipping OAuth preflight network check for client {client_info.client_id}: {e}') return True @@ -648,29 +668,23 @@ def get_client(self, client_id): self.ensure_client_from_config(client_id) client = self.clients.get(client_id) - return client["client"] if client else None + return client['client'] if client else None def get_client_info(self, client_id): if client_id not in self.clients: self.ensure_client_from_config(client_id) client = self.clients.get(client_id) - return client["client_info"] if client else None + return client['client_info'] if client else None def get_server_metadata_url(self, client_id): client = self.get_client(client_id) if not client: return None - return ( - client._server_metadata_url - if hasattr(client, "_server_metadata_url") - else None - ) + return client._server_metadata_url if hasattr(client, '_server_metadata_url') else None - async def get_oauth_token( - self, user_id: str, client_id: str, force_refresh: bool = False - ): + async def get_oauth_token(self, user_id: str, client_id: str, force_refresh: bool = False): """ Get a valid OAuth token for the user, automatically refreshing if needed. @@ -684,34 +698,26 @@ async def get_oauth_token( """ try: # Get the OAuth session - session = OAuthSessions.get_session_by_provider_and_user_id( - client_id, user_id - ) + session = OAuthSessions.get_session_by_provider_and_user_id(client_id, user_id) if not session: - log.warning( - f"No OAuth session found for user {user_id}, client_id {client_id}" - ) + log.warning(f'No OAuth session found for user {user_id}, client_id {client_id}') return None - if force_refresh or datetime.now() + timedelta( - minutes=5 - ) >= datetime.fromtimestamp(session.expires_at): - log.debug( - f"Token refresh needed for user {user_id}, client_id {session.provider}" - ) + if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at): + log.debug(f'Token refresh needed for user {user_id}, client_id {session.provider}') refreshed_token = await self._refresh_token(session) if refreshed_token: return refreshed_token else: log.warning( - f"Token refresh failed for user {user_id}, client_id {session.provider}, deleting session {session.id}" + f'Token refresh failed for user {user_id}, client_id {session.provider}, deleting session {session.id}' ) OAuthSessions.delete_session_by_id(session.id) return None return session.token except Exception as e: - log.error(f"Error getting OAuth token for user {user_id}: {e}") + log.error(f'Error getting OAuth token for user {user_id}: {e}') return None async def _refresh_token(self, session) -> dict: @@ -730,17 +736,15 @@ async def _refresh_token(self, session) -> dict: if refreshed_token: # Update the session with new token data - session = OAuthSessions.update_session_by_id( - session.id, refreshed_token - ) - log.info(f"Successfully refreshed token for session {session.id}") + session = OAuthSessions.update_session_by_id(session.id, refreshed_token) + log.info(f'Successfully refreshed token for session {session.id}') return session.token else: - log.error(f"Failed to refresh token for session {session.id}") + log.error(f'Failed to refresh token for session {session.id}') return None except Exception as e: - log.error(f"Error refreshing token for session {session.id}: {e}") + log.error(f'Error refreshing token for session {session.id}: {e}') return None async def _perform_token_refresh(self, session) -> dict: @@ -756,92 +760,78 @@ async def _perform_token_refresh(self, session) -> dict: client_id = session.provider token_data = session.token - if not token_data.get("refresh_token"): - log.warning(f"No refresh token available for session {session.id}") + if not token_data.get('refresh_token'): + log.warning(f'No refresh token available for session {session.id}') return None try: client = self.get_client(client_id) if not client: - log.error(f"No OAuth client found for provider {client_id}") + log.error(f'No OAuth client found for provider {client_id}') return None token_endpoint = None async with aiohttp.ClientSession(trust_env=True) as session_http: - async with session_http.get( - self.get_server_metadata_url(client_id) - ) as r: + async with session_http.get(self.get_server_metadata_url(client_id)) as r: if r.status == 200: openid_data = await r.json() - token_endpoint = openid_data.get("token_endpoint") + token_endpoint = openid_data.get('token_endpoint') else: - log.error( - f"Failed to fetch OpenID configuration for client_id {client_id}" - ) + log.error(f'Failed to fetch OpenID configuration for client_id {client_id}') if not token_endpoint: - log.error(f"No token endpoint found for client_id {client_id}") + log.error(f'No token endpoint found for client_id {client_id}') return None # Prepare refresh request refresh_data = { - "grant_type": "refresh_token", - "refresh_token": token_data["refresh_token"], - "client_id": client.client_id, + 'grant_type': 'refresh_token', + 'refresh_token': token_data['refresh_token'], + 'client_id': client.client_id, } - if hasattr(client, "client_secret") and client.client_secret: - refresh_data["client_secret"] = client.client_secret + if hasattr(client, 'client_secret') and client.client_secret: + refresh_data['client_secret'] = client.client_secret # Add scope if available in client kwargs (some providers require it on refresh) if ( - hasattr(client, "client_kwargs") - and client.client_kwargs.get("scope") - and getattr( - self.app.state.config, "OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE", False - ) + hasattr(client, 'client_kwargs') + and client.client_kwargs.get('scope') + and getattr(self.app.state.config, 'OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE', False) ): - refresh_data["scope"] = client.client_kwargs["scope"] + refresh_data['scope'] = client.client_kwargs['scope'] # Make refresh request async with aiohttp.ClientSession(trust_env=True) as session_http: async with session_http.post( token_endpoint, data=refresh_data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status == 200: new_token_data = await r.json() # Merge with existing token data (preserve refresh_token if not provided) - if "refresh_token" not in new_token_data: - new_token_data["refresh_token"] = token_data[ - "refresh_token" - ] + if 'refresh_token' not in new_token_data: + new_token_data['refresh_token'] = token_data['refresh_token'] # Add timestamp for tracking - new_token_data["issued_at"] = datetime.now().timestamp() + new_token_data['issued_at'] = datetime.now().timestamp() # Calculate expires_at if we have expires_in - if ( - "expires_in" in new_token_data - and "expires_at" not in new_token_data - ): - new_token_data["expires_at"] = int( - datetime.now().timestamp() - + new_token_data["expires_in"] + if 'expires_in' in new_token_data and 'expires_at' not in new_token_data: + new_token_data['expires_at'] = int( + datetime.now().timestamp() + new_token_data['expires_in'] ) - log.debug(f"Token refresh successful for client_id {client_id}") + log.debug(f'Token refresh successful for client_id {client_id}') return new_token_data else: error_text = await r.text() - log.error( - f"Token refresh failed for client_id {client_id}: {r.status} - {error_text}" - ) + log.error(f'Token refresh failed for client_id {client_id}: {r.status} - {error_text}') return None except Exception as e: - log.error(f"Exception during token refresh for client_id {client_id}: {e}") + log.error(f'Exception during token refresh for client_id {client_id}: {e}') return None async def handle_authorize(self, request, client_id: str) -> RedirectResponse: @@ -855,9 +845,7 @@ async def handle_authorize(self, request, client_id: str) -> RedirectResponse: if client_info is None: raise HTTPException(404) - redirect_uri = ( - client_info.redirect_uris[0] if client_info.redirect_uris else None - ) + redirect_uri = client_info.redirect_uris[0] if client_info.redirect_uris else None redirect_uri_str = str(redirect_uri) if redirect_uri else None return await client.authorize_redirect(request, redirect_uri_str) @@ -878,24 +866,20 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) # Validate that we received a proper token response # If token exchange failed (e.g., 401), we may get an error response instead - if token and not token.get("access_token"): - error_desc = token.get( - "error_description", token.get("error", "Unknown error") - ) - error_message = f"Token exchange failed: {error_desc}" - log.error(f"Invalid token response for client_id {client_id}: {token}") + if token and not token.get('access_token'): + error_desc = token.get('error_description', token.get('error', 'Unknown error')) + error_message = f'Token exchange failed: {error_desc}' + log.error(f'Invalid token response for client_id {client_id}: {token}') token = None if token: try: # Add timestamp for tracking - token["issued_at"] = datetime.now().timestamp() + token['issued_at'] = datetime.now().timestamp() # Calculate expires_at if we have expires_in - if "expires_in" in token and "expires_at" not in token: - token["expires_at"] = ( - datetime.now().timestamp() + token["expires_in"] - ) + if 'expires_in' in token and 'expires_at' not in token: + token['expires_at'] = datetime.now().timestamp() + token['expires_in'] # Clean up any existing sessions for this user/client_id first sessions = OAuthSessions.get_sessions_by_user_id(user_id) @@ -908,35 +892,29 @@ async def handle_callback(self, request, client_id: str, user_id: str, response) provider=client_id, token=token, ) - log.info( - f"Stored OAuth session server-side for user {user_id}, client_id {client_id}" - ) + log.info(f'Stored OAuth session server-side for user {user_id}, client_id {client_id}') except Exception as e: - error_message = "Failed to store OAuth session server-side" - log.error(f"Failed to store OAuth session server-side: {e}") + error_message = 'Failed to store OAuth session server-side' + log.error(f'Failed to store OAuth session server-side: {e}') else: if not error_message: - error_message = "Failed to obtain OAuth token" + error_message = 'Failed to obtain OAuth token' log.warning(error_message) except Exception as e: error_message = _build_oauth_callback_error_message(e) log.warning( - "OAuth callback error for user_id=%s client_id=%s: %s", + 'OAuth callback error for user_id=%s client_id=%s: %s', user_id, client_id, error_message, exc_info=True, ) - redirect_url = ( - str(request.app.state.config.WEBUI_URL or request.base_url) - ).rstrip("/") + redirect_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') if error_message: log.debug(error_message) - redirect_url = ( - f"{redirect_url}/?error={urllib.parse.quote_plus(error_message)}" - ) + redirect_url = f'{redirect_url}/?error={urllib.parse.quote_plus(error_message)}' return RedirectResponse(url=redirect_url, headers=response.headers) response = RedirectResponse(url=redirect_url, headers=response.headers) @@ -951,11 +929,11 @@ def __init__(self, app): self._clients = {} for name, provider_config in OAUTH_PROVIDERS.items(): - if "register" not in provider_config: - log.error(f"OAuth provider {name} missing register function") + if 'register' not in provider_config: + log.error(f'OAuth provider {name} missing register function') continue - client = provider_config["register"](self.oauth) + client = provider_config['register'](self.oauth) self._clients[name] = client def get_client(self, provider_name): @@ -966,16 +944,10 @@ def get_client(self, provider_name): def get_server_metadata_url(self, provider_name): if provider_name in self._clients: client = self._clients[provider_name] - return ( - client._server_metadata_url - if hasattr(client, "_server_metadata_url") - else None - ) + return client._server_metadata_url if hasattr(client, '_server_metadata_url') else None return None - async def get_oauth_token( - self, user_id: str, session_id: str, force_refresh: bool = False - ): + async def get_oauth_token(self, user_id: str, session_id: str, force_refresh: bool = False): """ Get a valid OAuth token for the user, automatically refreshing if needed. @@ -991,23 +963,17 @@ async def get_oauth_token( # Get the OAuth session session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id) if not session: - log.warning( - f"No OAuth session found for user {user_id}, session {session_id}" - ) + log.warning(f'No OAuth session found for user {user_id}, session {session_id}') return None - if force_refresh or datetime.now() + timedelta( - minutes=5 - ) >= datetime.fromtimestamp(session.expires_at): - log.debug( - f"Token refresh needed for user {user_id}, provider {session.provider}" - ) + if force_refresh or datetime.now() + timedelta(minutes=5) >= datetime.fromtimestamp(session.expires_at): + log.debug(f'Token refresh needed for user {user_id}, provider {session.provider}') refreshed_token = await self._refresh_token(session) if refreshed_token: return refreshed_token else: log.warning( - f"Token refresh failed for user {user_id}, provider {session.provider}, deleting session {session.id}" + f'Token refresh failed for user {user_id}, provider {session.provider}, deleting session {session.id}' ) OAuthSessions.delete_session_by_id(session.id) @@ -1015,7 +981,7 @@ async def get_oauth_token( return session.token except Exception as e: - log.error(f"Error getting OAuth token for user {user_id}: {e}") + log.error(f'Error getting OAuth token for user {user_id}: {e}') return None async def _refresh_token(self, session) -> dict: @@ -1034,17 +1000,15 @@ async def _refresh_token(self, session) -> dict: if refreshed_token: # Update the session with new token data - session = OAuthSessions.update_session_by_id( - session.id, refreshed_token - ) - log.info(f"Successfully refreshed token for session {session.id}") + session = OAuthSessions.update_session_by_id(session.id, refreshed_token) + log.info(f'Successfully refreshed token for session {session.id}') return session.token else: - log.error(f"Failed to refresh token for session {session.id}") + log.error(f'Failed to refresh token for session {session.id}') return None except Exception as e: - log.error(f"Error refreshing token for session {session.id}: {e}") + log.error(f'Error refreshing token for session {session.id}: {e}') return None async def _perform_token_refresh(self, session) -> dict: @@ -1060,14 +1024,14 @@ async def _perform_token_refresh(self, session) -> dict: provider = session.provider token_data = session.token - if not token_data.get("refresh_token"): - log.warning(f"No refresh token available for session {session.id}") + if not token_data.get('refresh_token'): + log.warning(f'No refresh token available for session {session.id}') return None try: client = self.get_client(provider) if not client: - log.error(f"No OAuth client found for provider {provider}") + log.error(f'No OAuth client found for provider {provider}') return None server_metadata_url = self.get_server_metadata_url(provider) @@ -1076,89 +1040,79 @@ async def _perform_token_refresh(self, session) -> dict: async with session_http.get(server_metadata_url) as r: if r.status == 200: openid_data = await r.json() - token_endpoint = openid_data.get("token_endpoint") + token_endpoint = openid_data.get('token_endpoint') else: - log.error( - f"Failed to fetch OpenID configuration for provider {provider}" - ) + log.error(f'Failed to fetch OpenID configuration for provider {provider}') if not token_endpoint: - log.error(f"No token endpoint found for provider {provider}") + log.error(f'No token endpoint found for provider {provider}') return None # Prepare refresh request refresh_data = { - "grant_type": "refresh_token", - "refresh_token": token_data["refresh_token"], - "client_id": client.client_id, + 'grant_type': 'refresh_token', + 'refresh_token': token_data['refresh_token'], + 'client_id': client.client_id, } # Add client_secret if available (some providers require it) - if hasattr(client, "client_secret") and client.client_secret: - refresh_data["client_secret"] = client.client_secret + if hasattr(client, 'client_secret') and client.client_secret: + refresh_data['client_secret'] = client.client_secret # Add scope if available in client kwargs (some providers require it on refresh) if ( - hasattr(client, "client_kwargs") - and client.client_kwargs.get("scope") + hasattr(client, 'client_kwargs') + and client.client_kwargs.get('scope') and auth_manager_config.OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE ): - refresh_data["scope"] = client.client_kwargs["scope"] + refresh_data['scope'] = client.client_kwargs['scope'] # Make refresh request async with aiohttp.ClientSession(trust_env=True) as session_http: async with session_http.post( token_endpoint, data=refresh_data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as r: if r.status == 200: new_token_data = await r.json() # Merge with existing token data (preserve refresh_token if not provided) - if "refresh_token" not in new_token_data: - new_token_data["refresh_token"] = token_data[ - "refresh_token" - ] + if 'refresh_token' not in new_token_data: + new_token_data['refresh_token'] = token_data['refresh_token'] # Add timestamp for tracking - new_token_data["issued_at"] = datetime.now().timestamp() + new_token_data['issued_at'] = datetime.now().timestamp() # Calculate expires_at if we have expires_in - if ( - "expires_in" in new_token_data - and "expires_at" not in new_token_data - ): - new_token_data["expires_at"] = int( - datetime.now().timestamp() - + new_token_data["expires_in"] + if 'expires_in' in new_token_data and 'expires_at' not in new_token_data: + new_token_data['expires_at'] = int( + datetime.now().timestamp() + new_token_data['expires_in'] ) - log.debug(f"Token refresh successful for provider {provider}") + log.debug(f'Token refresh successful for provider {provider}') return new_token_data else: error_text = await r.text() - log.error( - f"Token refresh failed for provider {provider}: {r.status} - {error_text}" - ) + log.error(f'Token refresh failed for provider {provider}: {r.status} - {error_text}') return None except Exception as e: - log.error(f"Exception during token refresh for provider {provider}: {e}") + log.error(f'Exception during token refresh for provider {provider}: {e}') return None def get_user_role(self, user, user_data): user_count = Users.get_num_users() if user and user_count == 1: # If the user is the only user, assign the role "admin" - actually repairs role for single user on login - log.debug("Assigning the only user the admin role") - return "admin" + log.debug('Assigning the only user the admin role') + return 'admin' if not user and user_count == 0: # If there are no users, assign the role "admin", as the first user will be an admin - log.debug("Assigning the first user the admin role") - return "admin" + log.debug('Assigning the first user the admin role') + return 'admin' if auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT: - log.debug("Running OAUTH Role management") + log.debug('Running OAUTH Role management') oauth_claim = auth_manager_config.OAUTH_ROLES_CLAIM oauth_allowed_roles = auth_manager_config.OAUTH_ALLOWED_ROLES oauth_admin_roles = auth_manager_config.OAUTH_ADMIN_ROLES @@ -1169,7 +1123,7 @@ def get_user_role(self, user, user_data): # Next block extracts the roles from the user data, accepting nested claims of any depth if oauth_claim and oauth_allowed_roles and oauth_admin_roles: claim_data = user_data - nested_claims = oauth_claim.split(".") + nested_claims = oauth_claim.split('.') for nested_claim in nested_claims: claim_data = claim_data.get(nested_claim, {}) @@ -1190,26 +1144,35 @@ def get_user_role(self, user, user_data): elif isinstance(claim_data, int): oauth_roles = [str(claim_data)] - log.debug(f"Oauth Roles claim: {oauth_claim}") - log.debug(f"User roles from oauth: {oauth_roles}") - log.debug(f"Accepted user roles: {oauth_allowed_roles}") - log.debug(f"Accepted admin roles: {oauth_admin_roles}") + log.debug(f'Oauth Roles claim: {oauth_claim}') + log.debug(f'User roles from oauth: {oauth_roles}') + log.debug(f'Accepted user roles: {oauth_allowed_roles}') + log.debug(f'Accepted admin roles: {oauth_admin_roles}') - # If any roles are found, check if they match the allowed or admin roles + # If roles are present in the token, they must match; otherwise deny access if oauth_roles: - # If role management is enabled, and matching roles are provided, use the roles + matched = False for allowed_role in oauth_allowed_roles: - # If the user has any of the allowed roles, assign the role "user" if allowed_role in oauth_roles: - log.debug("Assigned user the user role") - role = "user" + log.debug('Assigned user the user role') + role = 'user' + matched = True break for admin_role in oauth_admin_roles: - # If the user has any of the admin roles, assign the role "admin" if admin_role in oauth_roles: - log.debug("Assigned user the admin role") - role = "admin" + log.debug('Assigned user the admin role') + role = 'admin' + matched = True break + if not matched: + log.warning( + f'OAuth role management enabled but user roles do not match any allowed/admin roles. ' + f'User roles: {oauth_roles}, allowed: {oauth_allowed_roles}, admin: {oauth_admin_roles}' + ) + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) else: if not user: # If role management is disabled, use the default role for new users @@ -1221,20 +1184,20 @@ def get_user_role(self, user, user_data): return role def update_user_groups(self, user, user_data, default_permissions, db=None): - log.debug("Running OAUTH Group management") + log.debug('Running OAUTH Group management') oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM try: blocked_groups = json.loads(auth_manager_config.OAUTH_BLOCKED_GROUPS) except Exception as e: - log.exception(f"Error loading OAUTH_BLOCKED_GROUPS: {e}") + log.exception(f'Error loading OAUTH_BLOCKED_GROUPS: {e}') blocked_groups = [] user_oauth_groups = [] # Nested claim search for groups claim if oauth_claim: claim_data = user_data - nested_claims = oauth_claim.split(".") + nested_claims = oauth_claim.split('.') for nested_claim in nested_claims: claim_data = claim_data.get(nested_claim, {}) @@ -1249,41 +1212,31 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): else: user_oauth_groups = [] - user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id( - user.id, db=db - ) + user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id, db=db) all_available_groups: list[GroupModel] = Groups.get_all_groups(db=db) # Create groups if they don't exist and creation is enabled if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION: - log.debug("Checking for missing groups to create...") + log.debug('Checking for missing groups to create...') all_group_names = {g.name for g in all_available_groups} groups_created = False # Determine creator ID: Prefer admin, fallback to current user if no admin exists admin_user = Users.get_super_admin_user() creator_id = admin_user.id if admin_user else user.id - log.debug(f"Using creator ID {creator_id} for potential group creation.") + log.debug(f'Using creator ID {creator_id} for potential group creation.') for group_name in user_oauth_groups: if group_name not in all_group_names: - log.info( - f"Group '{group_name}' not found via OAuth claim. Creating group..." - ) + log.info(f"Group '{group_name}' not found via OAuth claim. Creating group...") try: new_group_form = GroupForm( name=group_name, description=f"Group '{group_name}' created automatically via OAuth.", permissions=default_permissions, # Use default permissions from function args - data={ - "config": { - "share": auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE - } - }, + data={'config': {'share': auth_manager_config.OAUTH_GROUP_DEFAULT_SHARE}}, ) # Use determined creator ID (admin or fallback to current user) - created_group = Groups.insert_new_group( - creator_id, new_group_form, db=db - ) + created_group = Groups.insert_new_group(creator_id, new_group_form, db=db) if created_group: log.info( f"Successfully created group '{group_name}' with ID {created_group.id} using creator ID {creator_id}" @@ -1292,23 +1245,19 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): # Add to local set to prevent duplicate creation attempts in this run all_group_names.add(group_name) else: - log.error( - f"Failed to create group '{group_name}' via OAuth." - ) + log.error(f"Failed to create group '{group_name}' via OAuth.") except Exception as e: log.error(f"Error creating group '{group_name}' via OAuth: {e}") # Refresh the list of all available groups if any were created if groups_created: all_available_groups = Groups.get_all_groups(db=db) - log.debug("Refreshed list of all available groups after creation.") + log.debug('Refreshed list of all available groups after creation.') - log.debug(f"Oauth Groups claim: {oauth_claim}") - log.debug(f"User oauth groups: {user_oauth_groups}") + log.debug(f'Oauth Groups claim: {oauth_claim}') + log.debug(f'User oauth groups: {user_oauth_groups}') log.debug(f"User's current groups: {[g.name for g in user_current_groups]}") - log.debug( - f"All groups available in OpenWebUI: {[g.name for g in all_available_groups]}" - ) + log.debug(f'All groups available in OpenWebUI: {[g.name for g in all_available_groups]}') # Remove groups that user is no longer a part of for group_model in user_current_groups: @@ -1318,9 +1267,7 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): and not is_in_blocked_groups(group_model.name, blocked_groups) ): # Remove group from user - log.debug( - f"Removing user from group {group_model.name} as it is no longer in their oauth groups" - ) + log.debug(f'Removing user from group {group_model.name} as it is no longer in their oauth groups') Groups.remove_users_from_group(group_model.id, [user.id], db=db) # In case a group is created, but perms are never assigned to the group by hitting "save" @@ -1348,9 +1295,7 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): and not is_in_blocked_groups(group_model.name, blocked_groups) ): # Add user to group - log.debug( - f"Adding user to group {group_model.name} as it was found in their oauth groups" - ) + log.debug(f'Adding user to group {group_model.name} as it was found in their oauth groups') Groups.add_users_to_group(group_model.id, [user.id], db=db) @@ -1370,9 +1315,7 @@ def update_user_groups(self, user, user_data, default_permissions, db=None): db=db, ) - async def _process_picture_url( - self, picture_url: str, access_token: str = None - ) -> str: + async def _process_picture_url(self, picture_url: str, access_token: str = None) -> str: """Process a picture URL and return a base64 encoded data URL. Args: @@ -1383,44 +1326,36 @@ async def _process_picture_url( A data URL containing the base64 encoded picture, or "/user.png" if processing fails """ if not picture_url: - return "/user.png" + return '/user.png' try: get_kwargs = {} if access_token: - get_kwargs["headers"] = { - "Authorization": f"Bearer {access_token}", + get_kwargs['headers'] = { + 'Authorization': f'Bearer {access_token}', } async with aiohttp.ClientSession(trust_env=True) as session: - async with session.get( - picture_url, **get_kwargs, ssl=AIOHTTP_CLIENT_SESSION_SSL - ) as resp: + async with session.get(picture_url, **get_kwargs, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp: if resp.ok: picture = await resp.read() - base64_encoded_picture = base64.b64encode(picture).decode( - "utf-8" - ) + base64_encoded_picture = base64.b64encode(picture).decode('utf-8') guessed_mime_type = mimetypes.guess_type(picture_url)[0] if guessed_mime_type is None: - guessed_mime_type = "image/jpeg" - return ( - f"data:{guessed_mime_type};base64,{base64_encoded_picture}" - ) + guessed_mime_type = 'image/jpeg' + return f'data:{guessed_mime_type};base64,{base64_encoded_picture}' else: - log.warning( - f"Failed to fetch profile picture from {picture_url}" - ) - return "/user.png" + log.warning(f'Failed to fetch profile picture from {picture_url}') + return '/user.png' except Exception as e: log.error(f"Error processing profile picture '{picture_url}': {e}") - return "/user.png" + return '/user.png' async def handle_login(self, request, provider): if provider not in OAUTH_PROVIDERS: raise HTTPException(404) # If the provider has a custom redirect URL, use that, otherwise automatically generate one - redirect_uri = OAUTH_PROVIDERS[provider].get("redirect_uri") or request.url_for( - "oauth_login_callback", provider=provider + redirect_uri = OAUTH_PROVIDERS[provider].get('redirect_uri') or request.url_for( + 'oauth_login_callback', provider=provider ) client = self.get_client(provider) if client is None: @@ -1428,7 +1363,9 @@ async def handle_login(self, request, provider): kwargs = {} if auth_manager_config.OAUTH_AUDIENCE: - kwargs["audience"] = auth_manager_config.OAUTH_AUDIENCE + kwargs['audience'] = auth_manager_config.OAUTH_AUDIENCE + if OAUTH_AUTHORIZE_PARAMS: + kwargs.update(OAUTH_AUTHORIZE_PARAMS) return await client.authorize_redirect(request, redirect_uri, **kwargs) @@ -1443,18 +1380,15 @@ async def handle_callback(self, request, provider, response, db=None): auth_params = {} if client: - if ( - hasattr(client, "client_id") - and OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID - ): - auth_params["client_id"] = client.client_id + if hasattr(client, 'client_id') and OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID: + auth_params['client_id'] = client.client_id try: token = await client.authorize_access_token(request, **auth_params) except Exception as e: detailed_error = _build_oauth_callback_error_message(e) log.warning( - "OAuth callback error during authorize_access_token for provider %s: %s", + 'OAuth callback error during authorize_access_token for provider %s: %s', provider, detailed_error, exc_info=True, @@ -1462,21 +1396,26 @@ async def handle_callback(self, request, provider, response, db=None): raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Try to get userinfo from the token first, some providers include it there - user_data: UserInfo = token.get("userinfo") + user_data: UserInfo = token.get('userinfo') + # Preserve extra claims from the ID token (e.g. roles, groups for + # Microsoft Entra ID) before the userinfo endpoint possibly overwrites them. + id_token_claims = dict(user_data) if user_data else {} if ( (not user_data) or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data) or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data) ): user_data: UserInfo = await client.userinfo(token=token) - if ( - provider == "feishu" - and isinstance(user_data, dict) - and "data" in user_data - ): - user_data = user_data["data"] + # Merge back ID token claims that the userinfo endpoint doesn't + # return. Only backfill missing keys so userinfo always wins. + if user_data and id_token_claims: + for key, value in id_token_claims.items(): + if key not in user_data: + user_data[key] = value + if provider == 'feishu' and isinstance(user_data, dict) and 'data' in user_data: + user_data = user_data['data'] if not user_data: - log.warning(f"OAuth callback failed, user data is missing: {token}") + log.warning(f'OAuth callback failed, user data is missing: {token}') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Extract the "sub" claim, using custom claim if configured @@ -1484,29 +1423,29 @@ async def handle_callback(self, request, provider, response, db=None): sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM) else: # Fallback to the default sub claim if not configured - sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub")) + sub = user_data.get(OAUTH_PROVIDERS[provider].get('sub_claim', 'sub')) if not sub: - log.warning(f"OAuth callback failed, sub is missing: {user_data}") + log.warning(f'OAuth callback failed, sub is missing: {user_data}') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) oauth_data = {} oauth_data[provider] = { - "sub": sub, + 'sub': sub, } # Email extraction email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM - email = user_data.get(email_claim, "") + email = user_data.get(email_claim, '') # We currently mandate that email addresses are provided if not email: # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email - if provider == "github": + if provider == 'github': try: - access_token = token.get("access_token") - headers = {"Authorization": f"Bearer {access_token}"} + access_token = token.get('access_token') + headers = {'Authorization': f'Bearer {access_token}'} async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( - "https://api.github.com/user/emails", + 'https://api.github.com/user/emails', headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, ) as resp: @@ -1514,46 +1453,33 @@ async def handle_callback(self, request, provider, response, db=None): emails = await resp.json() # use the primary email as the user's email primary_email = next( - ( - e["email"] - for e in emails - if e.get("primary") - ), + (e['email'] for e in emails if e.get('primary')), None, ) if primary_email: email = primary_email else: - log.warning( - "No primary email found in GitHub response" - ) - raise HTTPException( - 400, detail=ERROR_MESSAGES.INVALID_CRED - ) + log.warning('No primary email found in GitHub response') + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) else: - log.warning("Failed to fetch GitHub email") - raise HTTPException( - 400, detail=ERROR_MESSAGES.INVALID_CRED - ) + log.warning('Failed to fetch GitHub email') + raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) except Exception as e: - log.warning(f"Error fetching GitHub email: {e}") + log.warning(f'Error fetching GitHub email: {e}') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) elif ENABLE_OAUTH_EMAIL_FALLBACK: - email = f"{provider}@{sub}.local" + email = f'{provider}@{sub}.local' else: - log.warning(f"OAuth callback failed, email is missing: {user_data}") + log.warning(f'OAuth callback failed, email is missing: {user_data}') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) email = email.lower() # If allowed domains are configured, check if the email domain is in the list if ( - "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS - and email.split("@")[-1] - not in auth_manager_config.OAUTH_ALLOWED_DOMAINS + '*' not in auth_manager_config.OAUTH_ALLOWED_DOMAINS + and email.split('@')[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS ): - log.warning( - f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}" - ) + log.warning(f'OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}') raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED) # Check if the user exists @@ -1580,9 +1506,9 @@ async def handle_callback(self, request, provider, response, db=None): if username_claim: new_name = user_data.get(username_claim) if new_name and new_name != user.name: - Users.update_user_by_id(user.id, {"name": new_name}, db=db) + Users.update_user_by_id(user.id, {'name': new_name}, db=db) user.name = new_name - log.debug(f"Updated name for user {user.email}") + log.debug(f'Updated name for user {user.email}') if auth_manager_config.OAUTH_UPDATE_EMAIL_ON_LOGIN: email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM @@ -1592,14 +1518,12 @@ async def handle_callback(self, request, provider, response, db=None): existing_user = Users.get_user_by_email(new_email, db=db) if existing_user: log.error( - f"Cannot update email to {new_email} for user {user.id} because it is already taken." + f'Cannot update email to {new_email} for user {user.id} because it is already taken.' ) else: - Auths.update_email_by_id( - user.id, new_email.lower(), db=db - ) + Auths.update_email_by_id(user.id, new_email.lower(), db=db) user.email = new_email.lower() - log.debug(f"Updated email for user {user.id}") + log.debug(f'Updated email for user {user.id}') # Update profile picture if enabled and different from current if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN: @@ -1607,16 +1531,14 @@ async def handle_callback(self, request, provider, response, db=None): if picture_claim: new_picture_url = user_data.get( picture_claim, - OAUTH_PROVIDERS[provider].get("picture_url", ""), + OAUTH_PROVIDERS[provider].get('picture_url', ''), ) processed_picture_url = await self._process_picture_url( - new_picture_url, token.get("access_token") + new_picture_url, token.get('access_token') ) if processed_picture_url != user.profile_image_url: - Users.update_user_profile_image_url_by_id( - user.id, processed_picture_url, db=db - ) - log.debug(f"Updated profile picture for user {user.email}") + Users.update_user_profile_image_url_by_id(user.id, processed_picture_url, db=db) + log.debug(f'Updated profile picture for user {user.email}') else: # If the user does not exist, check if signups are enabled if auth_manager_config.ENABLE_OAUTH_SIGNUP: @@ -1629,25 +1551,21 @@ async def handle_callback(self, request, provider, response, db=None): if picture_claim: picture_url = user_data.get( picture_claim, - OAUTH_PROVIDERS[provider].get("picture_url", ""), - ) - picture_url = await self._process_picture_url( - picture_url, token.get("access_token") + OAUTH_PROVIDERS[provider].get('picture_url', ''), ) + picture_url = await self._process_picture_url(picture_url, token.get('access_token')) else: - picture_url = "/user.png" + picture_url = '/user.png' username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM name = user_data.get(username_claim) if not name: - log.warning("Username claim is missing, using email as name") + log.warning('Username claim is missing, using email as name') name = email user = Auths.insert_new_auth( email=email, - password=get_password_hash( - str(uuid.uuid4()) - ), # Random password, not used + password=get_password_hash(str(uuid.uuid4())), # Random password, not used name=name, profile_image_url=picture_url, role=self.get_user_role(None, user_data), @@ -1661,15 +1579,13 @@ async def handle_callback(self, request, provider, response, db=None): auth_manager_config.WEBHOOK_URL, WEBHOOK_MESSAGES.USER_SIGNUP(user.name), { - "action": "signup", - "message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name), - "user": user.model_dump_json(exclude_none=True), + 'action': 'signup', + 'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name), + 'user': user.model_dump_json(exclude_none=True), }, ) - apply_default_group_assignment( - request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db - ) + apply_default_group_assignment(request.app.state.config.DEFAULT_GROUP_ID, user.id, db=db) else: raise HTTPException( @@ -1678,13 +1594,10 @@ async def handle_callback(self, request, provider, response, db=None): ) jwt_token = create_token( - data={"id": user.id}, + data={'id': user.id}, expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN), ) - if ( - auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT - and user.role != "admin" - ): + if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT: self.update_user_groups( user=user, user_data=user_data, @@ -1693,53 +1606,55 @@ async def handle_callback(self, request, provider, response, db=None): ) except Exception as e: - log.error(f"Error during OAuth process: {e}") + log.error(f'Error during OAuth process: {e}') error_message = ( e.detail if isinstance(e, HTTPException) and e.detail - else ERROR_MESSAGES.DEFAULT("Error during OAuth process") + else ERROR_MESSAGES.DEFAULT('Error during OAuth process') ) - redirect_base_url = ( - str(request.app.state.config.WEBUI_URL or request.base_url) - ).rstrip("/") - redirect_url = f"{redirect_base_url}/auth" + redirect_base_url = (str(request.app.state.config.WEBUI_URL or request.base_url)).rstrip('/') + redirect_url = f'{redirect_base_url}/auth' if error_message: - redirect_url = ( - f"{redirect_url}?error={urllib.parse.quote_plus(error_message)}" - ) + redirect_url = f'{redirect_url}?error={urllib.parse.quote_plus(error_message)}' return RedirectResponse(url=redirect_url, headers=response.headers) response = RedirectResponse(url=redirect_url, headers=response.headers) + # Compute cookie expiry from JWT lifetime + expires_delta = parse_duration(auth_manager_config.JWT_EXPIRES_IN) + cookie_max_age = int(expires_delta.total_seconds()) if expires_delta else None + # Set the cookie token # Redirect back to the frontend with the JWT token response.set_cookie( - key="token", + key='token', value=jwt_token, httponly=False, # Required for frontend access samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': cookie_max_age} if cookie_max_age is not None else {}), ) # Legacy cookies for compatibility with older frontend versions if ENABLE_OAUTH_ID_TOKEN_COOKIE: response.set_cookie( - key="oauth_id_token", - value=token.get("id_token"), + key='oauth_id_token', + value=token.get('id_token'), httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': cookie_max_age} if cookie_max_age is not None else {}), ) try: # Add timestamp for tracking - token["issued_at"] = datetime.now().timestamp() + token['issued_at'] = datetime.now().timestamp() # Calculate expires_at if we have expires_in - if "expires_in" in token and "expires_at" not in token: - token["expires_at"] = datetime.now().timestamp() + token["expires_in"] + if 'expires_in' in token and 'expires_at' not in token: + token['expires_at'] = datetime.now().timestamp() + token['expires_in'] # Enforce max concurrent sessions per user/provider to prevent # unbounded growth while allowing multi-device usage @@ -1763,21 +1678,18 @@ async def handle_callback(self, request, provider, response, db=None): if session: response.set_cookie( - key="oauth_session_id", + key='oauth_session_id', value=session.id, httponly=True, samesite=WEBUI_AUTH_COOKIE_SAME_SITE, secure=WEBUI_AUTH_COOKIE_SECURE, + **({'max_age': cookie_max_age, 'expires': cookie_expires} if cookie_max_age is not None else {}), ) - log.info( - f"Stored OAuth session server-side for user {user.id}, provider {provider}" - ) + log.info(f'Stored OAuth session server-side for user {user.id}, provider {provider}') else: - log.warning( - f"Failed to create OAuth session for user {user.id}, provider {provider}" - ) + log.warning(f'Failed to create OAuth session for user {user.id}, provider {provider}') except Exception as e: - log.error(f"Failed to store OAuth session server-side: {e}") + log.error(f'Failed to store OAuth session server-side: {e}') return response diff --git a/backend/open_webui/utils/payload.py b/backend/open_webui/utils/payload.py index 168ec893b2..440927caf1 100644 --- a/backend/open_webui/utils/payload.py +++ b/backend/open_webui/utils/payload.py @@ -10,6 +10,8 @@ import json +# What goes out cannot be taken back. Let it be shaped +# well before it leaves this place. # inplace function: form_data is modified def apply_system_prompt_to_body( system: Optional[str], @@ -23,7 +25,7 @@ def apply_system_prompt_to_body( # Metadata (WebUI Usage) if metadata: - variables = metadata.get("variables", {}) + variables = metadata.get('variables', {}) if variables: system = prompt_variables_template(system, variables) @@ -31,21 +33,15 @@ def apply_system_prompt_to_body( system = prompt_template(system, user) if replace: - form_data["messages"] = replace_system_message_content( - system, form_data.get("messages", []) - ) + form_data['messages'] = replace_system_message_content(system, form_data.get('messages', [])) else: - form_data["messages"] = add_or_update_system_message( - system, form_data.get("messages", []) - ) + form_data['messages'] = add_or_update_system_message(system, form_data.get('messages', [])) return form_data # inplace function: form_data is modified -def apply_model_params_to_body( - params: dict, form_data: dict, mappings: dict[str, Callable] -) -> dict: +def apply_model_params_to_body(params: dict, form_data: dict, mappings: dict[str, Callable]) -> dict: if not params: return form_data @@ -72,11 +68,11 @@ def remove_open_webui_params(params: dict) -> dict: dict: The modified dictionary with OpenWebUI parameters removed. """ open_webui_params = { - "stream_response": bool, - "stream_delta_chunk_size": int, - "function_calling": str, - "reasoning_tags": list, - "system": str, + 'stream_response': bool, + 'stream_delta_chunk_size': int, + 'function_calling': str, + 'reasoning_tags': list, + 'system': str, } for key in list(params.keys()): @@ -90,7 +86,7 @@ def remove_open_webui_params(params: dict) -> dict: def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: params = remove_open_webui_params(params) - custom_params = params.pop("custom_params", {}) + custom_params = params.pop('custom_params', {}) if custom_params: # Attempt to parse custom_params if they are strings for key, value in custom_params.items(): @@ -106,17 +102,17 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: params = deep_update(params, custom_params) mappings = { - "temperature": float, - "top_p": float, - "min_p": float, - "max_tokens": int, - "frequency_penalty": float, - "presence_penalty": float, - "reasoning_effort": str, - "seed": lambda x: x, - "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], - "logit_bias": lambda x: x, - "response_format": dict, + 'temperature': float, + 'top_p': float, + 'min_p': float, + 'max_tokens': int, + 'frequency_penalty': float, + 'presence_penalty': float, + 'reasoning_effort': str, + 'seed': lambda x: x, + 'stop': lambda x: [bytes(s, 'utf-8').decode('unicode_escape') for s in x], + 'logit_bias': lambda x: x, + 'response_format': dict, } return apply_model_params_to_body(params, form_data, mappings) @@ -124,7 +120,7 @@ def apply_model_params_to_body_openai(params: dict, form_data: dict) -> dict: def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: params = remove_open_webui_params(params) - custom_params = params.pop("custom_params", {}) + custom_params = params.pop('custom_params', {}) if custom_params: # Attempt to parse custom_params if they are strings for key, value in custom_params.items(): @@ -141,7 +137,7 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: # Convert OpenAI parameter names to Ollama parameter names if needed. name_differences = { - "max_tokens": "num_predict", + 'max_tokens': 'num_predict', } for key, value in name_differences.items(): @@ -152,27 +148,27 @@ def apply_model_params_to_body_ollama(params: dict, form_data: dict) -> dict: # See https://github.com/ollama/ollama/blob/main/docs/api.md#request-8 mappings = { - "temperature": float, - "top_p": float, - "seed": lambda x: x, - "mirostat": int, - "mirostat_eta": float, - "mirostat_tau": float, - "num_ctx": int, - "num_batch": int, - "num_keep": int, - "num_predict": int, - "repeat_last_n": int, - "top_k": int, - "min_p": float, - "repeat_penalty": float, - "presence_penalty": float, - "frequency_penalty": float, - "stop": lambda x: [bytes(s, "utf-8").decode("unicode_escape") for s in x], - "num_gpu": int, - "use_mmap": bool, - "use_mlock": bool, - "num_thread": int, + 'temperature': float, + 'top_p': float, + 'seed': lambda x: x, + 'mirostat': int, + 'mirostat_eta': float, + 'mirostat_tau': float, + 'num_ctx': int, + 'num_batch': int, + 'num_keep': int, + 'num_predict': int, + 'repeat_last_n': int, + 'top_k': int, + 'min_p': float, + 'repeat_penalty': float, + 'presence_penalty': float, + 'frequency_penalty': float, + 'stop': lambda x: [bytes(s, 'utf-8').decode('unicode_escape') for s in x], + 'num_gpu': int, + 'use_mmap': bool, + 'use_mlock': bool, + 'num_thread': int, } def parse_json(value: str) -> dict: @@ -185,9 +181,9 @@ def parse_json(value: str) -> dict: return value ollama_root_params = { - "format": lambda x: parse_json(x), - "keep_alive": lambda x: parse_json(x), - "think": lambda x: x, + 'format': lambda x: parse_json(x), + 'keep_alive': lambda x: parse_json(x), + 'think': lambda x: x, } for key, value in ollama_root_params.items(): @@ -197,9 +193,7 @@ def parse_json(value: str) -> dict: del params[key] # Unlike OpenAI, Ollama does not support params directly in the body - form_data["options"] = apply_model_params_to_body( - params, (form_data.get("options", {}) or {}), mappings - ) + form_data['options'] = apply_model_params_to_body(params, (form_data.get('options', {}) or {}), mappings) return form_data @@ -208,68 +202,66 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]: for message in messages: # Initialize the new message structure with the role - new_message = {"role": message["role"]} + new_message = {'role': message['role']} - content = message.get("content", []) - tool_calls = message.get("tool_calls", None) - tool_call_id = message.get("tool_call_id", None) + content = message.get('content', []) + tool_calls = message.get('tool_calls', None) + tool_call_id = message.get('tool_call_id', None) # Check if the content is a string (just a simple message) if isinstance(content, str) and not tool_calls: # If the content is a string, it's pure text - new_message["content"] = content + new_message['content'] = content # If message is a tool call, add the tool call id to the message if tool_call_id: - new_message["tool_call_id"] = tool_call_id + new_message['tool_call_id'] = tool_call_id elif tool_calls: # If tool calls are present, add them to the message ollama_tool_calls = [] for tool_call in tool_calls: ollama_tool_call = { - "index": tool_call.get("index", 0), - "id": tool_call.get("id", None), - "function": { - "name": tool_call.get("function", {}).get("name", ""), - "arguments": json.loads( - tool_call.get("function", {}).get("arguments", {}) - ), + 'index': tool_call.get('index', 0), + 'id': tool_call.get('id', None), + 'function': { + 'name': tool_call.get('function', {}).get('name', ''), + 'arguments': json.loads(tool_call.get('function', {}).get('arguments', {})), }, } ollama_tool_calls.append(ollama_tool_call) - new_message["tool_calls"] = ollama_tool_calls + new_message['tool_calls'] = ollama_tool_calls # Put the content to empty string (Ollama requires an empty string for tool calls) - new_message["content"] = "" + new_message['content'] = '' else: # Otherwise, assume the content is a list of dicts, e.g., text followed by an image URL - content_text = "" + content_text = '' images = [] # Iterate through the list of content items for item in content: # Check if it's a text type - if item.get("type") == "text": - content_text += item.get("text", "") + if item.get('type') == 'text': + content_text += item.get('text', '') # Check if it's an image URL type - elif item.get("type") == "image_url": - img_url = item.get("image_url", {}).get("url", "") + elif item.get('type') == 'image_url': + img_url = item.get('image_url', {}).get('url', '') if img_url: # If the image url starts with data:, it's a base64 image and should be trimmed - if img_url.startswith("data:"): - img_url = img_url.split(",")[-1] + if img_url.startswith('data:'): + img_url = img_url.split(',')[-1] images.append(img_url) # Add content text (if any) if content_text: - new_message["content"] = content_text.strip() + new_message['content'] = content_text.strip() # Add images (if any) if images: - new_message["images"] = images + new_message['images'] = images # Append the new formatted message to the result ollama_messages.append(new_message) @@ -288,31 +280,27 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict: dict: A modified payload compatible with the Ollama API. """ # Shallow copy metadata separately (may contain non-picklable objects) - metadata = openai_payload.get("metadata") - openai_payload = copy.deepcopy( - {k: v for k, v in openai_payload.items() if k != "metadata"} - ) + metadata = openai_payload.get('metadata') + openai_payload = copy.deepcopy({k: v for k, v in openai_payload.items() if k != 'metadata'}) if metadata is not None: - openai_payload["metadata"] = dict(metadata) + openai_payload['metadata'] = dict(metadata) ollama_payload = {} # Mapping basic model and message details - ollama_payload["model"] = openai_payload.get("model") - ollama_payload["messages"] = convert_messages_openai_to_ollama( - openai_payload.get("messages") - ) - ollama_payload["stream"] = openai_payload.get("stream", False) - if "tools" in openai_payload: - ollama_payload["tools"] = openai_payload["tools"] - - if "max_tokens" in openai_payload: - ollama_payload["num_predict"] = openai_payload["max_tokens"] - del openai_payload["max_tokens"] + ollama_payload['model'] = openai_payload.get('model') + ollama_payload['messages'] = convert_messages_openai_to_ollama(openai_payload.get('messages')) + ollama_payload['stream'] = openai_payload.get('stream', False) + if 'tools' in openai_payload: + ollama_payload['tools'] = openai_payload['tools'] + + if 'max_tokens' in openai_payload: + ollama_payload['num_predict'] = openai_payload['max_tokens'] + del openai_payload['max_tokens'] # If there are advanced parameters in the payload, format them in Ollama's options field - if openai_payload.get("options"): - ollama_payload["options"] = openai_payload["options"] - ollama_options = openai_payload["options"] + if openai_payload.get('options'): + ollama_payload['options'] = openai_payload['options'] + ollama_options = openai_payload['options'] def parse_json(value: str) -> dict: """ @@ -324,9 +312,9 @@ def parse_json(value: str) -> dict: return value ollama_root_params = { - "format": lambda x: parse_json(x), - "keep_alive": lambda x: parse_json(x), - "think": lambda x: x, + 'format': lambda x: parse_json(x), + 'keep_alive': lambda x: parse_json(x), + 'think': lambda x: x, } # Ollama's options field can contain parameters that should be at the root level. @@ -337,35 +325,35 @@ def parse_json(value: str) -> dict: del ollama_options[key] # Re-Mapping OpenAI's `max_tokens` -> Ollama's `num_predict` - if "max_tokens" in ollama_options: - ollama_options["num_predict"] = ollama_options["max_tokens"] - del ollama_options["max_tokens"] + if 'max_tokens' in ollama_options: + ollama_options['num_predict'] = ollama_options['max_tokens'] + del ollama_options['max_tokens'] # Ollama lacks a "system" prompt option. It has to be provided as a direct parameter, so we copy it down. # Comment: Not sure why this is needed, but we'll keep it for compatibility. - if "system" in ollama_options: - ollama_payload["system"] = ollama_options["system"] - del ollama_options["system"] + if 'system' in ollama_options: + ollama_payload['system'] = ollama_options['system'] + del ollama_options['system'] - ollama_payload["options"] = ollama_options + ollama_payload['options'] = ollama_options # If there is the "stop" parameter in the openai_payload, remap it to the ollama_payload.options - if "stop" in openai_payload: - ollama_options = ollama_payload.get("options", {}) - ollama_options["stop"] = openai_payload.get("stop") - ollama_payload["options"] = ollama_options + if 'stop' in openai_payload: + ollama_options = ollama_payload.get('options', {}) + ollama_options['stop'] = openai_payload.get('stop') + ollama_payload['options'] = ollama_options - if "metadata" in openai_payload: - ollama_payload["metadata"] = openai_payload["metadata"] + if 'metadata' in openai_payload: + ollama_payload['metadata'] = openai_payload['metadata'] - if "response_format" in openai_payload: - response_format = openai_payload["response_format"] - format_type = response_format.get("type", None) + if 'response_format' in openai_payload: + response_format = openai_payload['response_format'] + format_type = response_format.get('type', None) schema = response_format.get(format_type, None) if schema: - format = schema.get("schema", None) - ollama_payload["format"] = format + format = schema.get('schema', None) + ollama_payload['format'] = format return ollama_payload @@ -380,19 +368,19 @@ def convert_embedding_payload_openai_to_ollama(openai_payload: dict) -> dict: Returns: dict: A payload compatible with the Ollama API embeddings endpoint. """ - ollama_payload = {"model": openai_payload.get("model")} - input_value = openai_payload.get("input") + ollama_payload = {'model': openai_payload.get('model')} + input_value = openai_payload.get('input') # Ollama expects 'input' as a list, and 'prompt' as a single string. if isinstance(input_value, list): - ollama_payload["input"] = input_value - ollama_payload["prompt"] = "\n".join(str(x) for x in input_value) + ollama_payload['input'] = input_value + ollama_payload['prompt'] = '\n'.join(str(x) for x in input_value) else: - ollama_payload["input"] = [input_value] - ollama_payload["prompt"] = str(input_value) + ollama_payload['input'] = [input_value] + ollama_payload['prompt'] = str(input_value) # Optionally forward other fields if present - for optional_key in ("options", "truncate", "keep_alive"): + for optional_key in ('options', 'truncate', 'keep_alive'): if optional_key in openai_payload: ollama_payload[optional_key] = openai_payload[optional_key] @@ -411,14 +399,14 @@ def convert_embed_payload_openai_to_ollama(openai_payload: dict) -> dict: Returns: dict: A payload compatible with the Ollama /api/embed endpoint. """ - ollama_payload = {"model": openai_payload.get("model")} - input_value = openai_payload.get("input") + ollama_payload = {'model': openai_payload.get('model')} + input_value = openai_payload.get('input') # /api/embed accepts 'input' as a string or list of strings directly - ollama_payload["input"] = input_value + ollama_payload['input'] = input_value # Optionally forward other fields if present - for optional_key in ("truncate", "options", "keep_alive"): + for optional_key in ('truncate', 'options', 'keep_alive'): if optional_key in openai_payload: ollama_payload[optional_key] = openai_payload[optional_key] diff --git a/backend/open_webui/utils/pdf_generator.py b/backend/open_webui/utils/pdf_generator.py index c137b49da0..3db4297a21 100644 --- a/backend/open_webui/utils/pdf_generator.py +++ b/backend/open_webui/utils/pdf_generator.py @@ -29,32 +29,32 @@ def __init__(self, form_data: ChatTitleMessagesForm): self.messages_html = None self.form_data = form_data - self.css = Path(STATIC_DIR / "assets" / "pdf-style.css").read_text() + self.css = Path(STATIC_DIR / 'assets' / 'pdf-style.css').read_text() def format_timestamp(self, timestamp: float) -> str: """Convert a UNIX timestamp to a formatted date string.""" try: date_time = datetime.fromtimestamp(timestamp) - return date_time.strftime("%Y-%m-%d, %H:%M:%S") + return date_time.strftime('%Y-%m-%d, %H:%M:%S') except (ValueError, TypeError) as e: # Log the error if necessary - return "" + return '' def _build_html_message(self, message: Dict[str, Any]) -> str: """Build HTML for a single message.""" - role = escape(message.get("role", "user")) - content = escape(message.get("content", "")) - timestamp = message.get("timestamp") + role = escape(message.get('role', 'user')) + content = escape(message.get('content', '')) + timestamp = message.get('timestamp') - model = escape(message.get("model") if role == "assistant" else "") + model = escape(message.get('model') if role == 'assistant' else '') - date_str = escape(self.format_timestamp(timestamp) if timestamp else "") + date_str = escape(self.format_timestamp(timestamp) if timestamp else '') # extends pymdownx extension to convert markdown to html. # - https://facelessuser.github.io/pymdown-extensions/usage_notes/ # html_content = markdown(content, extensions=["pymdownx.extra"]) - content = content.replace("\n", "
") + content = content.replace('\n', '
') html_message = f"""
@@ -106,32 +106,28 @@ def generate_chat_pdf(self) -> bytes: # When running using `pip install` the static directory is in the site packages. if not FONTS_DIR.exists(): - FONTS_DIR = Path(site.getsitepackages()[0]) / "static/fonts" + FONTS_DIR = Path(site.getsitepackages()[0]) / 'static/fonts' # When running using `pip install -e .` the static directory is in the site packages. # This path only works if `open-webui serve` is run from the root of this project. if not FONTS_DIR.exists(): - FONTS_DIR = Path(".") / "backend" / "static" / "fonts" + FONTS_DIR = Path('.') / 'backend' / 'static' / 'fonts' - pdf.add_font("NotoSans", "", f"{FONTS_DIR}/NotoSans-Regular.ttf") - pdf.add_font("NotoSans", "b", f"{FONTS_DIR}/NotoSans-Bold.ttf") - pdf.add_font("NotoSans", "i", f"{FONTS_DIR}/NotoSans-Italic.ttf") - pdf.add_font("NotoSansKR", "", f"{FONTS_DIR}/NotoSansKR-Regular.ttf") - pdf.add_font("NotoSansJP", "", f"{FONTS_DIR}/NotoSansJP-Regular.ttf") - pdf.add_font("NotoSansSC", "", f"{FONTS_DIR}/NotoSansSC-Regular.ttf") - pdf.add_font("Twemoji", "", f"{FONTS_DIR}/Twemoji.ttf") + pdf.add_font('NotoSans', '', f'{FONTS_DIR}/NotoSans-Regular.ttf') + pdf.add_font('NotoSans', 'b', f'{FONTS_DIR}/NotoSans-Bold.ttf') + pdf.add_font('NotoSans', 'i', f'{FONTS_DIR}/NotoSans-Italic.ttf') + pdf.add_font('NotoSansKR', '', f'{FONTS_DIR}/NotoSansKR-Regular.ttf') + pdf.add_font('NotoSansJP', '', f'{FONTS_DIR}/NotoSansJP-Regular.ttf') + pdf.add_font('NotoSansSC', '', f'{FONTS_DIR}/NotoSansSC-Regular.ttf') + pdf.add_font('Twemoji', '', f'{FONTS_DIR}/Twemoji.ttf') - pdf.set_font("NotoSans", size=12) - pdf.set_fallback_fonts( - ["NotoSansKR", "NotoSansJP", "NotoSansSC", "Twemoji"] - ) + pdf.set_font('NotoSans', size=12) + pdf.set_fallback_fonts(['NotoSansKR', 'NotoSansJP', 'NotoSansSC', 'Twemoji']) pdf.set_auto_page_break(auto=True, margin=15) # Build HTML messages - messages_html_list: List[str] = [ - self._build_html_message(msg) for msg in self.form_data.messages - ] - self.messages_html = "
" + "".join(messages_html_list) + "
" + messages_html_list: List[str] = [self._build_html_message(msg) for msg in self.form_data.messages] + self.messages_html = '
' + ''.join(messages_html_list) + '
' # Generate full HTML body self.html_body = self._generate_html_body() diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index a2d7b9ad11..46622e21ae 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -20,9 +20,7 @@ log = logging.getLogger(__name__) -def resolve_valves_schema_options( - valves_class: type, schema: dict, user: Any = None -) -> dict: +def resolve_valves_schema_options(valves_class: type, schema: dict, user: Any = None) -> dict: """ Resolve dynamic options in a Valves schema. @@ -66,16 +64,16 @@ def get_model_options(cls, __user__=None) -> list[dict]: Returns: Modified schema dict with resolved options """ - if not schema or "properties" not in schema: + if not schema or 'properties' not in schema: return schema # Make a copy to avoid mutating the original schema = dict(schema) - schema["properties"] = dict(schema.get("properties", {})) + schema['properties'] = dict(schema.get('properties', {})) - for prop_name, prop_schema in list(schema["properties"].items()): + for prop_name, prop_schema in list(schema['properties'].items()): # Get the original field info from the Pydantic model - if not hasattr(valves_class, "model_fields"): + if not hasattr(valves_class, 'model_fields'): continue field_info = valves_class.model_fields.get(prop_name) @@ -87,11 +85,11 @@ def get_model_options(cls, __user__=None) -> list[dict]: if not json_schema_extra or not isinstance(json_schema_extra, dict): continue - input_config = json_schema_extra.get("input") + input_config = json_schema_extra.get('input') if not input_config or not isinstance(input_config, dict): continue - options = input_config.get("options") + options = input_config.get('options') if options is None: continue @@ -105,9 +103,7 @@ def get_model_options(cls, __user__=None) -> list[dict]: elif isinstance(options, str) and options: method = getattr(valves_class, options, None) if method is None or not callable(method): - log.warning( - f"options '{options}' not found or not callable on {valves_class.__name__}" - ) + log.warning(f"options '{options}' not found or not callable on {valves_class.__name__}") continue try: @@ -118,40 +114,32 @@ def get_model_options(cls, __user__=None) -> list[dict]: # Prepare kwargs based on what the method accepts kwargs = {} - if "__user__" in params and user is not None: - kwargs["__user__"] = ( - user.model_dump() if hasattr(user, "model_dump") else user - ) - if "user" in params and user is not None: - kwargs["user"] = ( - user.model_dump() if hasattr(user, "model_dump") else user - ) + if '__user__' in params and user is not None: + kwargs['__user__'] = user.model_dump() if hasattr(user, 'model_dump') else user + if 'user' in params and user is not None: + kwargs['user'] = user.model_dump() if hasattr(user, 'model_dump') else user resolved_options = method(**kwargs) if kwargs else method() # Validate return type if not isinstance(resolved_options, list): - log.warning( - f"Method '{options}' did not return a list for {prop_name}" - ) + log.warning(f"Method '{options}' did not return a list for {prop_name}") continue except Exception as e: - log.warning(f"Failed to resolve options for {prop_name}: {e}") + log.warning(f'Failed to resolve options for {prop_name}: {e}') continue else: # Invalid options type - skip continue # Update the schema with resolved options - schema["properties"][prop_name] = dict(prop_schema) - if "input" not in schema["properties"][prop_name]: - schema["properties"][prop_name]["input"] = {"type": "select"} + schema['properties'][prop_name] = dict(prop_schema) + if 'input' not in schema['properties'][prop_name]: + schema['properties'][prop_name]['input'] = {'type': 'select'} else: - schema["properties"][prop_name]["input"] = dict( - schema["properties"][prop_name].get("input", {}) - ) - schema["properties"][prop_name]["input"]["options"] = resolved_options + schema['properties'][prop_name]['input'] = dict(schema['properties'][prop_name].get('input', {})) + schema['properties'][prop_name]['input']['options'] = resolved_options return schema @@ -163,7 +151,7 @@ def extract_frontmatter(content): frontmatter = {} frontmatter_started = False frontmatter_ended = False - frontmatter_pattern = re.compile(r"^\s*([a-z_]+):\s*(.*)\s*$", re.IGNORECASE) + frontmatter_pattern = re.compile(r'^\s*([a-z_]+):\s*(.*)\s*$', re.IGNORECASE) try: lines = content.splitlines() @@ -186,7 +174,7 @@ def extract_frontmatter(content): frontmatter[key.strip()] = value.strip() except Exception as e: - log.exception(f"Failed to extract frontmatter: {e}") + log.exception(f'Failed to extract frontmatter: {e}') return {} return frontmatter @@ -197,10 +185,10 @@ def replace_imports(content): Replace the import paths in the content. """ replacements = { - "from utils": "from open_webui.utils", - "from apps": "from open_webui.apps", - "from main": "from open_webui.main", - "from config": "from open_webui.config", + 'from utils': 'from open_webui.utils', + 'from apps': 'from open_webui.apps', + 'from main': 'from open_webui.main', + 'from config': 'from open_webui.config', } for old, new in replacements.items(): @@ -209,23 +197,24 @@ def replace_imports(content): return content +# May the intent of the one who wrote it survive every +# import and transformation, as a deed survives the generations. def load_tool_module_by_id(tool_id, content=None): - if content is None: tool = Tools.get_tool_by_id(tool_id) if not tool: - raise Exception(f"Toolkit not found: {tool_id}") + raise Exception(f'Toolkit not found: {tool_id}') content = tool.content content = replace_imports(content) - Tools.update_tool_by_id(tool_id, {"content": content}) + Tools.update_tool_by_id(tool_id, {'content': content}) else: frontmatter = extract_frontmatter(content) # Install required packages found within the frontmatter - install_frontmatter_requirements(frontmatter.get("requirements", "")) + install_frontmatter_requirements(frontmatter.get('requirements', '')) - module_name = f"tool_{tool_id}" + module_name = f'tool_{tool_id}' module = types.ModuleType(module_name) sys.modules[module_name] = module @@ -234,22 +223,22 @@ def load_tool_module_by_id(tool_id, content=None): temp_file = tempfile.NamedTemporaryFile(delete=False) temp_file.close() try: - with open(temp_file.name, "w", encoding="utf-8") as f: + with open(temp_file.name, 'w', encoding='utf-8') as f: f.write(content) - module.__dict__["__file__"] = temp_file.name + module.__dict__['__file__'] = temp_file.name # Executing the modified content in the created module's namespace exec(content, module.__dict__) frontmatter = extract_frontmatter(content) - log.info(f"Loaded module: {module.__name__}") + log.info(f'Loaded module: {module.__name__}') # Create and return the object if the class 'Tools' is found in the module - if hasattr(module, "Tools"): + if hasattr(module, 'Tools'): return module.Tools(), frontmatter else: - raise Exception("No Tools class found in the module") + raise Exception('No Tools class found in the module') except Exception as e: - log.error(f"Error loading module: {tool_id}: {e}") + log.error(f'Error loading module: {tool_id}: {e}') del sys.modules[module_name] # Clean up raise e finally: @@ -260,16 +249,16 @@ def load_function_module_by_id(function_id: str, content: str | None = None): if content is None: function = Functions.get_function_by_id(function_id) if not function: - raise Exception(f"Function not found: {function_id}") + raise Exception(f'Function not found: {function_id}') content = function.content content = replace_imports(content) - Functions.update_function_by_id(function_id, {"content": content}) + Functions.update_function_by_id(function_id, {'content': content}) else: frontmatter = extract_frontmatter(content) - install_frontmatter_requirements(frontmatter.get("requirements", "")) + install_frontmatter_requirements(frontmatter.get('requirements', '')) - module_name = f"function_{function_id}" + module_name = f'function_{function_id}' module = types.ModuleType(module_name) sys.modules[module_name] = module @@ -278,30 +267,30 @@ def load_function_module_by_id(function_id: str, content: str | None = None): temp_file = tempfile.NamedTemporaryFile(delete=False) temp_file.close() try: - with open(temp_file.name, "w", encoding="utf-8") as f: + with open(temp_file.name, 'w', encoding='utf-8') as f: f.write(content) - module.__dict__["__file__"] = temp_file.name + module.__dict__['__file__'] = temp_file.name # Execute the modified content in the created module's namespace exec(content, module.__dict__) frontmatter = extract_frontmatter(content) - log.info(f"Loaded module: {module.__name__}") + log.info(f'Loaded module: {module.__name__}') # Create appropriate object based on available class type in the module - if hasattr(module, "Pipe"): - return module.Pipe(), "pipe", frontmatter - elif hasattr(module, "Filter"): - return module.Filter(), "filter", frontmatter - elif hasattr(module, "Action"): - return module.Action(), "action", frontmatter + if hasattr(module, 'Pipe'): + return module.Pipe(), 'pipe', frontmatter + elif hasattr(module, 'Filter'): + return module.Filter(), 'filter', frontmatter + elif hasattr(module, 'Action'): + return module.Action(), 'action', frontmatter else: - raise Exception("No Function class found in the module") + raise Exception('No Function class found in the module') except Exception as e: - log.error(f"Error loading module: {function_id}: {e}") + log.error(f'Error loading module: {function_id}: {e}') # Cleanup by removing the module in case of error del sys.modules[module_name] - Functions.update_function_by_id(function_id, {"is_active": False}) + Functions.update_function_by_id(function_id, {'is_active': False}) raise e finally: os.unlink(temp_file.name) @@ -312,35 +301,32 @@ def get_tool_module_from_cache(request, tool_id, load_from_db=True): # Always load from the database by default tool = Tools.get_tool_by_id(tool_id) if not tool: - raise Exception(f"Tool not found: {tool_id}") + raise Exception(f'Tool not found: {tool_id}') content = tool.content new_content = replace_imports(content) if new_content != content: content = new_content # Update the tool content in the database - Tools.update_tool_by_id(tool_id, {"content": content}) + Tools.update_tool_by_id(tool_id, {'content': content}) - if ( - hasattr(request.app.state, "TOOL_CONTENTS") - and tool_id in request.app.state.TOOL_CONTENTS - ) and ( - hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS + if (hasattr(request.app.state, 'TOOL_CONTENTS') and tool_id in request.app.state.TOOL_CONTENTS) and ( + hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS ): if request.app.state.TOOL_CONTENTS[tool_id] == content: return request.app.state.TOOLS[tool_id], None tool_module, frontmatter = load_tool_module_by_id(tool_id, content) else: - if hasattr(request.app.state, "TOOLS") and tool_id in request.app.state.TOOLS: + if hasattr(request.app.state, 'TOOLS') and tool_id in request.app.state.TOOLS: return request.app.state.TOOLS[tool_id], None tool_module, frontmatter = load_tool_module_by_id(tool_id) - if not hasattr(request.app.state, "TOOLS"): + if not hasattr(request.app.state, 'TOOLS'): request.app.state.TOOLS = {} - if not hasattr(request.app.state, "TOOL_CONTENTS"): + if not hasattr(request.app.state, 'TOOL_CONTENTS'): request.app.state.TOOL_CONTENTS = {} request.app.state.TOOLS[tool_id] = tool_module @@ -357,46 +343,35 @@ def get_function_module_from_cache(request, function_id, load_from_db=True): function = Functions.get_function_by_id(function_id) if not function: - raise Exception(f"Function not found: {function_id}") + raise Exception(f'Function not found: {function_id}') content = function.content new_content = replace_imports(content) if new_content != content: content = new_content # Update the function content in the database - Functions.update_function_by_id(function_id, {"content": content}) + Functions.update_function_by_id(function_id, {'content': content}) if ( - hasattr(request.app.state, "FUNCTION_CONTENTS") - and function_id in request.app.state.FUNCTION_CONTENTS - ) and ( - hasattr(request.app.state, "FUNCTIONS") - and function_id in request.app.state.FUNCTIONS - ): + hasattr(request.app.state, 'FUNCTION_CONTENTS') and function_id in request.app.state.FUNCTION_CONTENTS + ) and (hasattr(request.app.state, 'FUNCTIONS') and function_id in request.app.state.FUNCTIONS): if request.app.state.FUNCTION_CONTENTS[function_id] == content: return request.app.state.FUNCTIONS[function_id], None, None - function_module, function_type, frontmatter = load_function_module_by_id( - function_id, content - ) + function_module, function_type, frontmatter = load_function_module_by_id(function_id, content) else: # Load from cache (e.g. "stream" hook) # This is useful for performance reasons - if ( - hasattr(request.app.state, "FUNCTIONS") - and function_id in request.app.state.FUNCTIONS - ): + if hasattr(request.app.state, 'FUNCTIONS') and function_id in request.app.state.FUNCTIONS: return request.app.state.FUNCTIONS[function_id], None, None - function_module, function_type, frontmatter = load_function_module_by_id( - function_id - ) + function_module, function_type, frontmatter = load_function_module_by_id(function_id) - if not hasattr(request.app.state, "FUNCTIONS"): + if not hasattr(request.app.state, 'FUNCTIONS'): request.app.state.FUNCTIONS = {} - if not hasattr(request.app.state, "FUNCTION_CONTENTS"): + if not hasattr(request.app.state, 'FUNCTION_CONTENTS'): request.app.state.FUNCTION_CONTENTS = {} request.app.state.FUNCTIONS[function_id] = function_module @@ -407,31 +382,26 @@ def get_function_module_from_cache(request, function_id, load_from_db=True): def install_frontmatter_requirements(requirements: str): if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS: - log.info( - "ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements." - ) + log.info('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements.') return if OFFLINE_MODE: - log.info("Offline mode enabled, skipping installation of requirements.") + log.info('Offline mode enabled, skipping installation of requirements.') return if requirements: try: - req_list = [req.strip() for req in requirements.split(",")] - log.info(f"Installing requirements: {' '.join(req_list)}") + req_list = [req.strip() for req in requirements.split(',')] + log.info(f'Installing requirements: {" ".join(req_list)}') subprocess.check_call( - [sys.executable, "-m", "pip", "install"] - + PIP_OPTIONS - + req_list - + PIP_PACKAGE_INDEX_OPTIONS + [sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + req_list + PIP_PACKAGE_INDEX_OPTIONS ) except Exception as e: - log.error(f"Error installing packages: {' '.join(req_list)}") + log.error(f'Error installing packages: {" ".join(req_list)}') raise e else: - log.info("No requirements found in frontmatter.") + log.info('No requirements found in frontmatter.') def install_tool_and_function_dependencies(): @@ -445,19 +415,19 @@ def install_tool_and_function_dependencies(): function_list = Functions.get_functions(active_only=True) tool_list = Tools.get_tools() - all_dependencies = "" + all_dependencies = '' try: for function in function_list: frontmatter = extract_frontmatter(replace_imports(function.content)) - if dependencies := frontmatter.get("requirements"): - all_dependencies += f"{dependencies}, " + if dependencies := frontmatter.get('requirements'): + all_dependencies += f'{dependencies}, ' for tool in tool_list: # Only install requirements for admin tools - if tool.user and tool.user.role == "admin": + if tool.user and tool.user.role == 'admin': frontmatter = extract_frontmatter(replace_imports(tool.content)) - if dependencies := frontmatter.get("requirements"): - all_dependencies += f"{dependencies}, " + if dependencies := frontmatter.get('requirements'): + all_dependencies += f'{dependencies}, ' - install_frontmatter_requirements(all_dependencies.strip(", ")) + install_frontmatter_requirements(all_dependencies.strip(', ')) except Exception as e: - log.error(f"Error installing requirements: {e}") + log.error(f'Error installing requirements: {e}') diff --git a/backend/open_webui/utils/rate_limit.py b/backend/open_webui/utils/rate_limit.py index b657a937ab..93f3851d1f 100644 --- a/backend/open_webui/utils/rate_limit.py +++ b/backend/open_webui/utils/rate_limit.py @@ -35,7 +35,7 @@ def __init__( self.enabled = enabled def _bucket_key(self, key: str, bucket_index: int) -> str: - return f"{REDIS_KEY_PREFIX}:ratelimit:{key.lower()}:{bucket_index}" + return f'{REDIS_KEY_PREFIX}:ratelimit:{key.lower()}:{bucket_index}' def _current_bucket(self) -> int: return int(time.time()) // self.bucket_size @@ -84,9 +84,7 @@ def _is_limited_redis(self, key: str) -> bool: self.r.expire(bucket_key, self.window + self.bucket_size) # Collect buckets - buckets = [ - self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1) - ] + buckets = [self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1)] counts = self.r.mget(buckets) total = sum(int(c) for c in counts if c) @@ -95,9 +93,7 @@ def _is_limited_redis(self, key: str) -> bool: def _get_count_redis(self, key: str) -> int: now_bucket = self._current_bucket() - buckets = [ - self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1) - ] + buckets = [self._bucket_key(key, now_bucket - i) for i in range(self.num_buckets + 1)] counts = self.r.mget(buckets) return sum(int(c) for c in counts if c) diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index fcc4879ba3..55d08147a9 100644 --- a/backend/open_webui/utils/redis.py +++ b/backend/open_webui/utils/redis.py @@ -20,6 +20,9 @@ log = logging.getLogger(__name__) +# Let not our connections be timed out but deliver them from +# partition. For the cache and the socket and the uptime +# belong to the one who first opened them, now and always. _CONNECTION_CACHE = {} @@ -40,7 +43,7 @@ def __getattr__(self, item): if not callable(orig_attr): return orig_attr - FACTORY_METHODS = {"pipeline", "pubsub", "monitor", "client", "transaction"} + FACTORY_METHODS = {'pipeline', 'pubsub', 'monitor', 'client', 'transaction'} if item in FACTORY_METHODS: return orig_attr @@ -61,7 +64,7 @@ async def _iter(): ) as e: if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1: log.debug( - "Redis sentinel fail-over (%s). Retry %s/%s", + 'Redis sentinel fail-over (%s). Retry %s/%s', type(e).__name__, i + 1, REDIS_SENTINEL_MAX_RETRY_COUNT, @@ -70,7 +73,7 @@ async def _iter(): time.sleep(REDIS_RECONNECT_DELAY / 1000) continue log.error( - "Redis operation failed after %s retries: %s", + 'Redis operation failed after %s retries: %s', REDIS_SENTINEL_MAX_RETRY_COUNT, e, ) @@ -94,7 +97,7 @@ async def _wrapped(*args, **kwargs): ) as e: if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1: log.debug( - "Redis sentinel fail-over (%s). Retry %s/%s", + 'Redis sentinel fail-over (%s). Retry %s/%s', type(e).__name__, i + 1, REDIS_SENTINEL_MAX_RETRY_COUNT, @@ -103,7 +106,7 @@ async def _wrapped(*args, **kwargs): await asyncio.sleep(REDIS_RECONNECT_DELAY / 1000) continue log.error( - "Redis operation failed after %s retries: %s", + 'Redis operation failed after %s retries: %s', REDIS_SENTINEL_MAX_RETRY_COUNT, e, ) @@ -124,7 +127,7 @@ def _wrapped(*args, **kwargs): ) as e: if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1: log.debug( - "Redis sentinel fail-over (%s). Retry %s/%s", + 'Redis sentinel fail-over (%s). Retry %s/%s', type(e).__name__, i + 1, REDIS_SENTINEL_MAX_RETRY_COUNT, @@ -133,7 +136,7 @@ def _wrapped(*args, **kwargs): time.sleep(REDIS_RECONNECT_DELAY / 1000) continue log.error( - "Redis operation failed after %s retries: %s", + 'Redis operation failed after %s retries: %s', REDIS_SENTINEL_MAX_RETRY_COUNT, e, ) @@ -144,15 +147,15 @@ def _wrapped(*args, **kwargs): def parse_redis_service_url(redis_url): parsed_url = urlparse(redis_url) - if parsed_url.scheme != "redis" and parsed_url.scheme != "rediss": + if parsed_url.scheme != 'redis' and parsed_url.scheme != 'rediss': raise ValueError("Invalid Redis URL scheme. Must be 'redis' or 'rediss'.") return { - "username": parsed_url.username or None, - "password": parsed_url.password or None, - "service": parsed_url.hostname or "mymaster", - "port": parsed_url.port or 6379, - "db": int(parsed_url.path.lstrip("/") or 0), + 'username': parsed_url.username or None, + 'password': parsed_url.password or None, + 'service': parsed_url.hostname or 'mymaster', + 'port': parsed_url.port or 6379, + 'db': int(parsed_url.path.lstrip('/') or 0), } @@ -160,14 +163,12 @@ def get_redis_client(async_mode=False): try: return get_redis_connection( redis_url=REDIS_URL, - redis_sentinels=get_sentinels_from_env( - REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT - ), + redis_sentinels=get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT), redis_cluster=REDIS_CLUSTER, async_mode=async_mode, ) except Exception as e: - log.debug(f"Failed to get Redis client: {e}") + log.debug(f'Failed to get Redis client: {e}') return None @@ -178,7 +179,6 @@ def get_redis_connection( async_mode=False, decode_responses=True, ): - cache_key = ( redis_url, tuple(redis_sentinels) if redis_sentinels else (), @@ -199,24 +199,22 @@ def get_redis_connection( redis_config = parse_redis_service_url(redis_url) sentinel = redis.sentinel.Sentinel( redis_sentinels, - port=redis_config["port"], - db=redis_config["db"], - username=redis_config["username"], - password=redis_config["password"], + port=redis_config['port'], + db=redis_config['db'], + username=redis_config['username'], + password=redis_config['password'], decode_responses=decode_responses, socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT, ) connection = SentinelRedisProxy( sentinel, - redis_config["service"], + redis_config['service'], async_mode=async_mode, ) elif redis_cluster: if not redis_url: - raise ValueError("Redis URL must be provided for cluster mode.") - return redis.cluster.RedisCluster.from_url( - redis_url, decode_responses=decode_responses - ) + raise ValueError('Redis URL must be provided for cluster mode.') + return redis.cluster.RedisCluster.from_url(redis_url, decode_responses=decode_responses) elif redis_url: connection = redis.from_url(redis_url, decode_responses=decode_responses) else: @@ -226,28 +224,24 @@ def get_redis_connection( redis_config = parse_redis_service_url(redis_url) sentinel = redis.sentinel.Sentinel( redis_sentinels, - port=redis_config["port"], - db=redis_config["db"], - username=redis_config["username"], - password=redis_config["password"], + port=redis_config['port'], + db=redis_config['db'], + username=redis_config['username'], + password=redis_config['password'], decode_responses=decode_responses, socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT, ) connection = SentinelRedisProxy( sentinel, - redis_config["service"], + redis_config['service'], async_mode=async_mode, ) elif redis_cluster: if not redis_url: - raise ValueError("Redis URL must be provided for cluster mode.") - return redis.cluster.RedisCluster.from_url( - redis_url, decode_responses=decode_responses - ) + raise ValueError('Redis URL must be provided for cluster mode.') + return redis.cluster.RedisCluster.from_url(redis_url, decode_responses=decode_responses) elif redis_url: - connection = redis.Redis.from_url( - redis_url, decode_responses=decode_responses - ) + connection = redis.Redis.from_url(redis_url, decode_responses=decode_responses) _CONNECTION_CACHE[cache_key] = connection return connection @@ -255,7 +249,7 @@ def get_redis_connection( def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env): if sentinel_hosts_env: - sentinel_hosts = sentinel_hosts_env.split(",") + sentinel_hosts = sentinel_hosts_env.split(',') sentinel_port = int(sentinel_port_env) return [(host, sentinel_port) for host in sentinel_hosts] return [] @@ -263,12 +257,10 @@ def get_sentinels_from_env(sentinel_hosts_env, sentinel_port_env): def get_sentinel_url_from_env(redis_url, sentinel_hosts_env, sentinel_port_env): redis_config = parse_redis_service_url(redis_url) - username = redis_config["username"] or "" - password = redis_config["password"] or "" - auth_part = "" + username = redis_config['username'] or '' + password = redis_config['password'] or '' + auth_part = '' if username or password: - auth_part = f"{username}:{password}@" - hosts_part = ",".join( - f"{host}:{sentinel_port_env}" for host in sentinel_hosts_env.split(",") - ) - return f"redis+sentinel://{auth_part}{hosts_part}/{redis_config['db']}/{redis_config['service']}" + auth_part = f'{username}:{password}@' + hosts_part = ','.join(f'{host}:{sentinel_port_env}' for host in sentinel_hosts_env.split(',')) + return f'redis+sentinel://{auth_part}{hosts_part}/{redis_config["db"]}/{redis_config["service"]}' diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index 583c7c0043..3ef37cb9b2 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -8,6 +8,8 @@ ) +# An honest ledger is worth more than a flattering one. +# Let every cost here be counted true. def normalize_usage(usage: dict) -> dict: """ Normalize usage statistics to standard format. @@ -23,28 +25,28 @@ def normalize_usage(usage: dict) -> dict: # Map various field names to standard names input_tokens = ( - usage.get("input_tokens") # Already standard - or usage.get("prompt_tokens") # OpenAI - or usage.get("prompt_eval_count") # Ollama - or usage.get("prompt_n") # llama.cpp + usage.get('input_tokens') # Already standard + or usage.get('prompt_tokens') # OpenAI + or usage.get('prompt_eval_count') # Ollama + or usage.get('prompt_n') # llama.cpp or 0 ) output_tokens = ( - usage.get("output_tokens") # Already standard - or usage.get("completion_tokens") # OpenAI - or usage.get("eval_count") # Ollama - or usage.get("predicted_n") # llama.cpp + usage.get('output_tokens') # Already standard + or usage.get('completion_tokens') # OpenAI + or usage.get('eval_count') # Ollama + or usage.get('predicted_n') # llama.cpp or 0 ) - total_tokens = usage.get("total_tokens") or (input_tokens + output_tokens) + total_tokens = usage.get('total_tokens') or (input_tokens + output_tokens) # Add standardized fields to original data result = dict(usage) - result["input_tokens"] = int(input_tokens) - result["output_tokens"] = int(output_tokens) - result["total_tokens"] = int(total_tokens) + result['input_tokens'] = int(input_tokens) + result['output_tokens'] = int(output_tokens) + result['total_tokens'] = int(total_tokens) return result @@ -52,14 +54,14 @@ def normalize_usage(usage: dict) -> dict: def convert_ollama_tool_call_to_openai(tool_calls: list) -> list: openai_tool_calls = [] for tool_call in tool_calls: - function = tool_call.get("function", {}) + function = tool_call.get('function', {}) openai_tool_call = { - "index": tool_call.get("index", function.get("index", 0)), - "id": tool_call.get("id", f"call_{str(uuid4())}"), - "type": "function", - "function": { - "name": function.get("name", ""), - "arguments": json.dumps(function.get("arguments", {})), + 'index': tool_call.get('index', function.get('index', 0)), + 'id': tool_call.get('id', f'call_{str(uuid4())}'), + 'type': 'function', + 'function': { + 'name': function.get('name', ''), + 'arguments': json.dumps(function.get('arguments', {})), }, } openai_tool_calls.append(openai_tool_call) @@ -67,69 +69,57 @@ def convert_ollama_tool_call_to_openai(tool_calls: list) -> list: def convert_ollama_usage_to_openai(data: dict) -> dict: - input_tokens = int(data.get("prompt_eval_count", 0)) - output_tokens = int(data.get("eval_count", 0)) + input_tokens = int(data.get('prompt_eval_count', 0)) + output_tokens = int(data.get('eval_count', 0)) total_tokens = input_tokens + output_tokens return { # Standardized fields - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "total_tokens": total_tokens, + 'input_tokens': input_tokens, + 'output_tokens': output_tokens, + 'total_tokens': total_tokens, # OpenAI-compatible fields (for backward compatibility) - "prompt_tokens": input_tokens, - "completion_tokens": output_tokens, + 'prompt_tokens': input_tokens, + 'completion_tokens': output_tokens, # Ollama-specific metrics - "response_token/s": ( + 'response_token/s': ( round( - ( - ( - data.get("eval_count", 0) - / ((data.get("eval_duration", 0) / 10_000_000)) - ) - * 100 - ), + ((data.get('eval_count', 0) / (data.get('eval_duration', 0) / 10_000_000)) * 100), 2, ) - if data.get("eval_duration", 0) > 0 - else "N/A" + if data.get('eval_duration', 0) > 0 + else 'N/A' ), - "prompt_token/s": ( + 'prompt_token/s': ( round( - ( - ( - data.get("prompt_eval_count", 0) - / ((data.get("prompt_eval_duration", 0) / 10_000_000)) - ) - * 100 - ), + ((data.get('prompt_eval_count', 0) / (data.get('prompt_eval_duration', 0) / 10_000_000)) * 100), 2, ) - if data.get("prompt_eval_duration", 0) > 0 - else "N/A" + if data.get('prompt_eval_duration', 0) > 0 + else 'N/A' ), - "total_duration": data.get("total_duration", 0), - "load_duration": data.get("load_duration", 0), - "prompt_eval_count": data.get("prompt_eval_count", 0), - "prompt_eval_duration": data.get("prompt_eval_duration", 0), - "eval_count": data.get("eval_count", 0), - "eval_duration": data.get("eval_duration", 0), - "approximate_total": (lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s")( - (data.get("total_duration", 0) or 0) // 1_000_000_000 + 'total_duration': data.get('total_duration', 0), + 'load_duration': data.get('load_duration', 0), + 'prompt_eval_count': data.get('prompt_eval_count', 0), + 'prompt_eval_duration': data.get('prompt_eval_duration', 0), + 'eval_count': data.get('eval_count', 0), + 'eval_duration': data.get('eval_duration', 0), + 'approximate_total': (lambda s: f'{s // 3600}h{(s % 3600) // 60}m{s % 60}s')( + (data.get('total_duration', 0) or 0) // 1_000_000_000 ), - "completion_tokens_details": { - "reasoning_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0, + 'completion_tokens_details': { + 'reasoning_tokens': 0, + 'accepted_prediction_tokens': 0, + 'rejected_prediction_tokens': 0, }, } def convert_response_ollama_to_openai(ollama_response: dict) -> dict: - model = ollama_response.get("model", "ollama") - message_content = ollama_response.get("message", {}).get("content", "") - reasoning_content = ollama_response.get("message", {}).get("thinking", None) - tool_calls = ollama_response.get("message", {}).get("tool_calls", None) + model = ollama_response.get('model', 'ollama') + message_content = ollama_response.get('message', {}).get('content', '') + reasoning_content = ollama_response.get('message', {}).get('thinking', None) + tool_calls = ollama_response.get('message', {}).get('tool_calls', None) openai_tool_calls = None if tool_calls: @@ -156,17 +146,17 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) async for data in ollama_streaming_response.body_iterator: data = json.loads(data) - model = data.get("model", "ollama") - message_content = data.get("message", {}).get("content", None) - reasoning_content = data.get("message", {}).get("thinking", None) - tool_calls = data.get("message", {}).get("tool_calls", None) + model = data.get('model', 'ollama') + message_content = data.get('message', {}).get('content', None) + reasoning_content = data.get('message', {}).get('thinking', None) + tool_calls = data.get('message', {}).get('tool_calls', None) openai_tool_calls = None if tool_calls: openai_tool_calls = convert_ollama_tool_call_to_openai(tool_calls) has_tool_calls = True - done = data.get("done", False) + done = data.get('done', False) usage = None if done: @@ -177,15 +167,15 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) ) if done and has_tool_calls: - data["choices"][0]["finish_reason"] = "tool_calls" + data['choices'][0]['finish_reason'] = 'tool_calls' - line = f"data: {json.dumps(data)}\n\n" + line = f'data: {json.dumps(data)}\n\n' credit_deduct.run(line) yield line yield credit_deduct.usage_message - yield "data: [DONE]\n\n" + yield 'data: [DONE]\n\n' def convert_embedding_response_ollama_to_openai(response) -> dict: @@ -210,51 +200,47 @@ def convert_embedding_response_ollama_to_openai(response) -> dict: """ # Ollama batch-style output from /api/embed # Response format: {"embeddings": [[0.1, 0.2, ...], [0.3, 0.4, ...]], "model": "..."} - if isinstance(response, dict) and "embeddings" in response: + if isinstance(response, dict) and 'embeddings' in response: openai_data = [] - for i, emb in enumerate(response["embeddings"]): + for i, emb in enumerate(response['embeddings']): # /api/embed returns embeddings as plain float lists if isinstance(emb, list): openai_data.append( { - "object": "embedding", - "embedding": emb, - "index": i, + 'object': 'embedding', + 'embedding': emb, + 'index': i, } ) # Also handle dict format for robustness elif isinstance(emb, dict): openai_data.append( { - "object": "embedding", - "embedding": emb.get("embedding"), - "index": emb.get("index", i), + 'object': 'embedding', + 'embedding': emb.get('embedding'), + 'index': emb.get('index', i), } ) return { - "object": "list", - "data": openai_data, - "model": response.get("model"), + 'object': 'list', + 'data': openai_data, + 'model': response.get('model'), } # Ollama single output - elif isinstance(response, dict) and "embedding" in response: + elif isinstance(response, dict) and 'embedding' in response: return { - "object": "list", - "data": [ + 'object': 'list', + 'data': [ { - "object": "embedding", - "embedding": response["embedding"], - "index": 0, + 'object': 'embedding', + 'embedding': response['embedding'], + 'index': 0, } ], - "model": response.get("model"), + 'model': response.get('model'), } # Already OpenAI-compatible? - elif ( - isinstance(response, dict) - and "data" in response - and isinstance(response["data"], list) - ): + elif isinstance(response, dict) and 'data' in response and isinstance(response['data'], list): return response # Fallback: return as is if unrecognized diff --git a/backend/open_webui/utils/sanitize.py b/backend/open_webui/utils/sanitize.py index 258b6d78fb..7b65df375a 100644 --- a/backend/open_webui/utils/sanitize.py +++ b/backend/open_webui/utils/sanitize.py @@ -2,9 +2,7 @@ # ANSI escape code pattern - matches all common ANSI sequences # This includes color codes, cursor movement, and other terminal control sequences -ANSI_ESCAPE_PATTERN = re.compile( - r"\x1b\[[0-9;]*[A-Za-z]|\x1b\([AB]|\x1b[PX^_].*?\x1b\\|\x1b\].*?(?:\x07|\x1b\\)" -) +ANSI_ESCAPE_PATTERN = re.compile(r'\x1b\[[0-9;]*[A-Za-z]|\x1b\([AB]|\x1b[PX^_].*?\x1b\\|\x1b\].*?(?:\x07|\x1b\\)') def strip_ansi_codes(text: str) -> str: @@ -20,7 +18,7 @@ def strip_ansi_codes(text: str) -> str: - Reset codes: \x1b[0m, \x1b[39m - Cursor movement: \x1b[1A, \x1b[2J, etc. """ - return ANSI_ESCAPE_PATTERN.sub("", text) + return ANSI_ESCAPE_PATTERN.sub('', text) def strip_markdown_code_fences(code: str) -> str: @@ -37,9 +35,9 @@ def strip_markdown_code_fences(code: str) -> str: """ code = code.strip() # Remove opening fence (```python, ```py, ``` etc.) - code = re.sub(r"^```\w*\n?", "", code) + code = re.sub(r'^```\w*\n?', '', code) # Remove closing fence - code = re.sub(r"\n?```\s*$", "", code) + code = re.sub(r'\n?```\s*$', '', code) return code.strip() diff --git a/backend/open_webui/utils/security_headers.py b/backend/open_webui/utils/security_headers.py index 3b31c2c05c..33956688a1 100644 --- a/backend/open_webui/utils/security_headers.py +++ b/backend/open_webui/utils/security_headers.py @@ -39,16 +39,16 @@ def set_security_headers() -> Dict[str, str]: """ options = {} header_setters = { - "CACHE_CONTROL": set_cache_control, - "HSTS": set_hsts, - "PERMISSIONS_POLICY": set_permissions_policy, - "REFERRER_POLICY": set_referrer, - "XCONTENT_TYPE": set_xcontent_type, - "XDOWNLOAD_OPTIONS": set_xdownload_options, - "XFRAME_OPTIONS": set_xframe, - "XPERMITTED_CROSS_DOMAIN_POLICIES": set_xpermitted_cross_domain_policies, - "CONTENT_SECURITY_POLICY": set_content_security_policy, - "REPORTING_ENDPOINTS": set_reporting_endpoints, + 'CACHE_CONTROL': set_cache_control, + 'HSTS': set_hsts, + 'PERMISSIONS_POLICY': set_permissions_policy, + 'REFERRER_POLICY': set_referrer, + 'XCONTENT_TYPE': set_xcontent_type, + 'XDOWNLOAD_OPTIONS': set_xdownload_options, + 'XFRAME_OPTIONS': set_xframe, + 'XPERMITTED_CROSS_DOMAIN_POLICIES': set_xpermitted_cross_domain_policies, + 'CONTENT_SECURITY_POLICY': set_content_security_policy, + 'REPORTING_ENDPOINTS': set_reporting_endpoints, } for env_var, setter in header_setters.items(): @@ -63,78 +63,78 @@ def set_security_headers() -> Dict[str, str]: # Set HTTP Strict Transport Security(HSTS) response header def set_hsts(value: str): - pattern = r"^max-age=(\d+)(;includeSubDomains)?(;preload)?$" + pattern = r'^max-age=(\d+)(;includeSubDomains)?(;preload)?$' match = re.match(pattern, value, re.IGNORECASE) if not match: - value = "max-age=31536000;includeSubDomains" - return {"Strict-Transport-Security": value} + value = 'max-age=31536000;includeSubDomains' + return {'Strict-Transport-Security': value} # Set X-Frame-Options response header def set_xframe(value: str): - pattern = r"^(DENY|SAMEORIGIN)$" + pattern = r'^(DENY|SAMEORIGIN)$' match = re.match(pattern, value, re.IGNORECASE) if not match: - value = "DENY" - return {"X-Frame-Options": value} + value = 'DENY' + return {'X-Frame-Options': value} # Set Permissions-Policy response header def set_permissions_policy(value: str): - pattern = r"^(?:(accelerometer|autoplay|camera|clipboard-read|clipboard-write|fullscreen|geolocation|gyroscope|magnetometer|microphone|midi|payment|picture-in-picture|sync-xhr|usb|xr-spatial-tracking)=\((self)?\),?)*$" + pattern = r'^(?:(accelerometer|autoplay|camera|clipboard-read|clipboard-write|fullscreen|geolocation|gyroscope|magnetometer|microphone|midi|payment|picture-in-picture|sync-xhr|usb|xr-spatial-tracking)=\((self)?\),?)*$' match = re.match(pattern, value, re.IGNORECASE) if not match: - value = "none" - return {"Permissions-Policy": value} + value = 'none' + return {'Permissions-Policy': value} # Set Referrer-Policy response header def set_referrer(value: str): - pattern = r"^(no-referrer|no-referrer-when-downgrade|origin|origin-when-cross-origin|same-origin|strict-origin|strict-origin-when-cross-origin|unsafe-url)$" + pattern = r'^(no-referrer|no-referrer-when-downgrade|origin|origin-when-cross-origin|same-origin|strict-origin|strict-origin-when-cross-origin|unsafe-url)$' match = re.match(pattern, value, re.IGNORECASE) if not match: - value = "no-referrer" - return {"Referrer-Policy": value} + value = 'no-referrer' + return {'Referrer-Policy': value} # Set Cache-Control response header def set_cache_control(value: str): - pattern = r"^(public|private|no-cache|no-store|must-revalidate|proxy-revalidate|max-age=\d+|s-maxage=\d+|no-transform|immutable)(,\s*(public|private|no-cache|no-store|must-revalidate|proxy-revalidate|max-age=\d+|s-maxage=\d+|no-transform|immutable))*$" + pattern = r'^(public|private|no-cache|no-store|must-revalidate|proxy-revalidate|max-age=\d+|s-maxage=\d+|no-transform|immutable)(,\s*(public|private|no-cache|no-store|must-revalidate|proxy-revalidate|max-age=\d+|s-maxage=\d+|no-transform|immutable))*$' match = re.match(pattern, value, re.IGNORECASE) if not match: - value = "no-store, max-age=0" + value = 'no-store, max-age=0' - return {"Cache-Control": value} + return {'Cache-Control': value} # Set X-Download-Options response header def set_xdownload_options(value: str): - if value != "noopen": - value = "noopen" - return {"X-Download-Options": value} + if value != 'noopen': + value = 'noopen' + return {'X-Download-Options': value} # Set X-Content-Type-Options response header def set_xcontent_type(value: str): - if value != "nosniff": - value = "nosniff" - return {"X-Content-Type-Options": value} + if value != 'nosniff': + value = 'nosniff' + return {'X-Content-Type-Options': value} # Set X-Permitted-Cross-Domain-Policies response header def set_xpermitted_cross_domain_policies(value: str): - pattern = r"^(none|master-only|by-content-type|by-ftp-filename)$" + pattern = r'^(none|master-only|by-content-type|by-ftp-filename)$' match = re.match(pattern, value, re.IGNORECASE) if not match: - value = "none" - return {"X-Permitted-Cross-Domain-Policies": value} + value = 'none' + return {'X-Permitted-Cross-Domain-Policies': value} # Set Content-Security-Policy response header def set_content_security_policy(value: str): - return {"Content-Security-Policy": value} + return {'Content-Security-Policy': value} # Set Reporting-Endpoints response header def set_reporting_endpoints(value: str): - return {"Reporting-Endpoints": value} + return {'Reporting-Endpoints': value} diff --git a/backend/open_webui/utils/smtp.py b/backend/open_webui/utils/smtp.py index 6b6043b4d3..bb6af33944 100644 --- a/backend/open_webui/utils/smtp.py +++ b/backend/open_webui/utils/smtp.py @@ -13,19 +13,19 @@ def send_email(receiver: str, subject: str, body: str): message = MIMEMultipart() - message["From"] = SMTP_SENT_FROM.value or SMTP_USERNAME.value - message["To"] = receiver - message["Subject"] = subject - message.attach(MIMEText(body, "html")) + message['From'] = SMTP_SENT_FROM.value or SMTP_USERNAME.value + message['To'] = receiver + message['Subject'] = subject + message.attach(MIMEText(body, 'html')) port = str(SMTP_PORT.value) - if port == "587": + if port == '587': server = smtplib.SMTP(SMTP_HOST.value, int(port)) server.starttls() - elif port == "465": + elif port == '465': server = smtplib.SMTP_SSL(SMTP_HOST.value, int(port)) else: - raise ValueError(f"Invalid SMTP port {port}") + raise ValueError(f'Invalid SMTP port {port}') try: server.login(SMTP_USERNAME.value, SMTP_PASSWORD.value) diff --git a/backend/open_webui/utils/task.py b/backend/open_webui/utils/task.py index 0ea525c93e..203c429d22 100644 --- a/backend/open_webui/utils/task.py +++ b/backend/open_webui/utils/task.py @@ -13,13 +13,13 @@ log = logging.getLogger(__name__) -def get_task_model_id( - default_model_id: str, task_model: str, task_model_external: str, models -) -> str: +# Let the right tool be given for the work at hand, +# not the one that flatters, but the one that serves. +def get_task_model_id(default_model_id: str, task_model: str, task_model_external: str, models) -> str: # Set the task model task_model_id = default_model_id # Check if the user has a custom task model and use that model - if models[task_model_id].get("connection_type") == "local": + if models[task_model_id].get('connection_type') == 'local': if task_model and task_model in models: task_model_id = task_model else: @@ -36,92 +36,70 @@ def prompt_variables_template(template: str, variables: dict[str, str]) -> str: def prompt_template(template: str, user: Optional[Any] = None) -> str: - USER_VARIABLES = {} if user: - if hasattr(user, "model_dump"): + if hasattr(user, 'model_dump'): user = user.model_dump() if isinstance(user, dict): - user_info = user.get("info", {}) or {} - birth_date = user.get("date_of_birth") + user_info = user.get('info', {}) or {} + birth_date = user.get('date_of_birth') age = None if birth_date: try: # If birth_date is str, convert to datetime if isinstance(birth_date, str): - birth_date = datetime.strptime(birth_date, "%Y-%m-%d") + birth_date = datetime.strptime(birth_date, '%Y-%m-%d') today = datetime.now() - age = ( - today.year - - birth_date.year - - ( - (today.month, today.day) - < (birth_date.month, birth_date.day) - ) - ) + age = today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day)) except Exception as e: pass USER_VARIABLES = { - "name": str(user.get("name")), - "email": str(user.get("email")), - "location": str(user_info.get("location")), - "bio": str(user.get("bio")), - "gender": str(user.get("gender")), - "birth_date": str(birth_date), - "age": str(age), + 'name': str(user.get('name')), + 'email': str(user.get('email')), + 'location': str(user_info.get('location')), + 'bio': str(user.get('bio')), + 'gender': str(user.get('gender')), + 'birth_date': str(birth_date), + 'age': str(age), } # Get the current date current_date = datetime.now() # Format the date to YYYY-MM-DD - formatted_date = current_date.strftime("%Y-%m-%d") - formatted_time = current_date.strftime("%I:%M:%S %p") - formatted_weekday = current_date.strftime("%A") - - template = template.replace("{{CURRENT_DATE}}", formatted_date) - template = template.replace("{{CURRENT_TIME}}", formatted_time) - template = template.replace( - "{{CURRENT_DATETIME}}", f"{formatted_date} {formatted_time}" - ) - template = template.replace("{{CURRENT_WEEKDAY}}", formatted_weekday) - - template = template.replace("{{USER_NAME}}", USER_VARIABLES.get("name", "Unknown")) - template = template.replace( - "{{USER_EMAIL}}", USER_VARIABLES.get("email", "Unknown") - ) - template = template.replace("{{USER_BIO}}", USER_VARIABLES.get("bio", "Unknown")) - template = template.replace( - "{{USER_GENDER}}", USER_VARIABLES.get("gender", "Unknown") - ) - template = template.replace( - "{{USER_BIRTH_DATE}}", USER_VARIABLES.get("birth_date", "Unknown") - ) - template = template.replace( - "{{USER_AGE}}", str(USER_VARIABLES.get("age", "Unknown")) - ) - template = template.replace( - "{{USER_LOCATION}}", USER_VARIABLES.get("location", "Unknown") - ) + formatted_date = current_date.strftime('%Y-%m-%d') + formatted_time = current_date.strftime('%I:%M:%S %p') + formatted_weekday = current_date.strftime('%A') + + template = template.replace('{{CURRENT_DATE}}', formatted_date) + template = template.replace('{{CURRENT_TIME}}', formatted_time) + template = template.replace('{{CURRENT_DATETIME}}', f'{formatted_date} {formatted_time}') + template = template.replace('{{CURRENT_WEEKDAY}}', formatted_weekday) + + template = template.replace('{{USER_NAME}}', USER_VARIABLES.get('name', 'Unknown')) + template = template.replace('{{USER_EMAIL}}', USER_VARIABLES.get('email', 'Unknown')) + template = template.replace('{{USER_BIO}}', USER_VARIABLES.get('bio', 'Unknown')) + template = template.replace('{{USER_GENDER}}', USER_VARIABLES.get('gender', 'Unknown')) + template = template.replace('{{USER_BIRTH_DATE}}', USER_VARIABLES.get('birth_date', 'Unknown')) + template = template.replace('{{USER_AGE}}', str(USER_VARIABLES.get('age', 'Unknown'))) + template = template.replace('{{USER_LOCATION}}', USER_VARIABLES.get('location', 'Unknown')) return template def replace_prompt_variable(template: str, prompt: str) -> str: def replacement_function(match): - full_match = match.group( - 0 - ).lower() # Normalize to lowercase for consistent handling + full_match = match.group(0).lower() # Normalize to lowercase for consistent handling start_length = match.group(1) end_length = match.group(2) middle_length = match.group(3) - if full_match == "{{prompt}}": + if full_match == '{{prompt}}': return prompt elif start_length is not None: return prompt[: int(start_length)] @@ -133,16 +111,16 @@ def replacement_function(match): return prompt start = prompt[: math.ceil(middle_length / 2)] end = prompt[-math.floor(middle_length / 2) :] - return f"{start}...{end}" - return "" + return f'{start}...{end}' + return '' # Updated regex pattern to make it case-insensitive with the `(?i)` flag - pattern = r"(?i){{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}" + pattern = r'(?i){{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}' template = re.sub(pattern, replacement_function, template) return template -def truncate_content(content: str, max_chars: int, mode: str = "middletruncate") -> str: +def truncate_content(content: str, max_chars: int, mode: str = 'middletruncate') -> str: """Truncate a string to max_chars using the specified mode. Modes: @@ -153,13 +131,13 @@ def truncate_content(content: str, max_chars: int, mode: str = "middletruncate") if not content or len(content) <= max_chars: return content - if mode == "start": + if mode == 'start': return content[:max_chars] - elif mode == "end": + elif mode == 'end': return content[-max_chars:] else: # middletruncate half = max_chars // 2 - return f"{content[:half]}...{content[-(max_chars - half):]}" + return f'{content[:half]}...{content[-(max_chars - half) :]}' def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]: @@ -168,7 +146,7 @@ def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]: filter_str is like 'middletruncate:500', 'start:200', or 'end:200'. Returns a new list with truncated content (original messages are not mutated). """ - parts = filter_str.split(":") + parts = filter_str.split(':') if len(parts) != 2: return messages @@ -178,33 +156,29 @@ def apply_content_filter(messages: list[dict], filter_str: str) -> list[dict]: except ValueError: return messages - if mode not in ("middletruncate", "start", "end"): + if mode not in ('middletruncate', 'start', 'end'): return messages result = [] for msg in messages: new_msg = dict(msg) - if isinstance(new_msg.get("content"), str): - new_msg["content"] = truncate_content(new_msg["content"], max_chars, mode) - elif isinstance(new_msg.get("content"), list): + if isinstance(new_msg.get('content'), str): + new_msg['content'] = truncate_content(new_msg['content'], max_chars, mode) + elif isinstance(new_msg.get('content'), list): new_content = [] - for item in new_msg["content"]: - if isinstance(item, dict) and item.get("type") == "text": + for item in new_msg['content']: + if isinstance(item, dict) and item.get('type') == 'text': new_item = dict(item) - new_item["text"] = truncate_content( - item.get("text", ""), max_chars, mode - ) + new_item['text'] = truncate_content(item.get('text', ''), max_chars, mode) new_content.append(new_item) else: new_content.append(item) - new_msg["content"] = new_content + new_msg['content'] = new_content result.append(new_msg) return result -def replace_messages_variable( - template: str, messages: Optional[list[dict]] = None -) -> str: +def replace_messages_variable(template: str, messages: Optional[list[dict]] = None) -> str: def replacement_function(match): # Groups: (1) filter for bare MESSAGES # (2) START count, (3) filter for START @@ -220,7 +194,7 @@ def replacement_function(match): # If messages is None, handle it as an empty list if messages is None: - return "" + return '' # Select messages based on the variant if start_length is not None: @@ -251,12 +225,12 @@ def replacement_function(match): return get_messages_content(selected) template = re.sub( - r"(?:" - r"\{\{MESSAGES(?:\|(\w+:\d+))?\}\}" - r"|\{\{MESSAGES:START:(\d+)(?:\|(\w+:\d+))?\}\}" - r"|\{\{MESSAGES:END:(\d+)(?:\|(\w+:\d+))?\}\}" - r"|\{\{MESSAGES:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}\}" - r")", + r'(?:' + r'\{\{MESSAGES(?:\|(\w+:\d+))?\}\}' + r'|\{\{MESSAGES:START:(\d+)(?:\|(\w+:\d+))?\}\}' + r'|\{\{MESSAGES:END:(\d+)(?:\|(\w+:\d+))?\}\}' + r'|\{\{MESSAGES:MIDDLETRUNCATE:(\d+)(?:\|(\w+:\d+))?\}\}' + r')', replacement_function, template, ) @@ -267,40 +241,40 @@ def replacement_function(match): # {{prompt:middletruncate:8000}} +# Let the context given here not distort the question, +# but illuminate it, so that the answer serves the one who asked. def rag_template(template: str, context: str, query: str): - if template.strip() == "": + if template.strip() == '': template = DEFAULT_RAG_TEMPLATE template = prompt_template(template) - if "[context]" not in template and "{{CONTEXT}}" not in template: - log.debug( - "WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder." - ) + if '[context]' not in template and '{{CONTEXT}}' not in template: + log.debug("WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder.") - if "" in context and "" in context: + if '' in context and '' in context: log.debug( - "WARNING: Potential prompt injection attack: the RAG " + 'WARNING: Potential prompt injection attack: the RAG ' "context contains '' and ''. This might be " - "nothing, or the user might be trying to hack something." + 'nothing, or the user might be trying to hack something.' ) query_placeholders = [] - if "[query]" in context: - query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}" - template = template.replace("[query]", query_placeholder) - query_placeholders.append((query_placeholder, "[query]")) + if '[query]' in context: + query_placeholder = '{{QUERY' + str(uuid.uuid4()) + '}}' + template = template.replace('[query]', query_placeholder) + query_placeholders.append((query_placeholder, '[query]')) - if "{{QUERY}}" in context: - query_placeholder = "{{QUERY" + str(uuid.uuid4()) + "}}" - template = template.replace("{{QUERY}}", query_placeholder) - query_placeholders.append((query_placeholder, "{{QUERY}}")) + if '{{QUERY}}' in context: + query_placeholder = '{{QUERY' + str(uuid.uuid4()) + '}}' + template = template.replace('{{QUERY}}', query_placeholder) + query_placeholders.append((query_placeholder, '{{QUERY}}')) - template = template.replace("[context]", context) - template = template.replace("{{CONTEXT}}", context) + template = template.replace('[context]', context) + template = template.replace('{{CONTEXT}}', context) - template = template.replace("[query]", query) - template = template.replace("{{QUERY}}", query) + template = template.replace('[query]', query) + template = template.replace('{{QUERY}}', query) for query_placeholder, original_placeholder in query_placeholders: template = template.replace(query_placeholder, original_placeholder) @@ -308,10 +282,7 @@ def rag_template(template: str, context: str, query: str): return template -def title_generation_template( - template: str, messages: list[dict], user: Optional[Any] = None -) -> str: - +def title_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) @@ -321,9 +292,7 @@ def title_generation_template( return template -def follow_up_generation_template( - template: str, messages: list[dict], user: Optional[Any] = None -) -> str: +def follow_up_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) @@ -332,9 +301,7 @@ def follow_up_generation_template( return template -def tags_generation_template( - template: str, messages: list[dict], user: Optional[Any] = None -) -> str: +def tags_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) @@ -343,9 +310,7 @@ def tags_generation_template( return template -def image_prompt_generation_template( - template: str, messages: list[dict], user: Optional[Any] = None -) -> str: +def image_prompt_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) @@ -354,9 +319,7 @@ def image_prompt_generation_template( return template -def emoji_generation_template( - template: str, prompt: str, user: Optional[Any] = None -) -> str: +def emoji_generation_template(template: str, prompt: str, user: Optional[Any] = None) -> str: template = replace_prompt_variable(template, prompt) template = prompt_template(template, user) @@ -370,7 +333,7 @@ def autocomplete_generation_template( type: Optional[str] = None, user: Optional[Any] = None, ) -> str: - template = template.replace("{{TYPE}}", type if type else "") + template = template.replace('{{TYPE}}', type if type else '') template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) @@ -378,9 +341,7 @@ def autocomplete_generation_template( return template -def query_generation_template( - template: str, messages: list[dict], user: Optional[Any] = None -) -> str: +def query_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str: prompt = get_last_user_message(messages) template = replace_prompt_variable(template, prompt) template = replace_messages_variable(template, messages) @@ -389,16 +350,14 @@ def query_generation_template( return template -def moa_response_generation_template( - template: str, prompt: str, responses: list[str] -) -> str: +def moa_response_generation_template(template: str, prompt: str, responses: list[str]) -> str: def replacement_function(match): full_match = match.group(0) start_length = match.group(1) end_length = match.group(2) middle_length = match.group(3) - if full_match == "{{prompt}}": + if full_match == '{{prompt}}': return prompt elif start_length is not None: return prompt[: int(start_length)] @@ -410,22 +369,22 @@ def replacement_function(match): return prompt start = prompt[: math.ceil(middle_length / 2)] end = prompt[-math.floor(middle_length / 2) :] - return f"{start}...{end}" - return "" + return f'{start}...{end}' + return '' template = re.sub( - r"{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}", + r'{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}', replacement_function, template, ) responses = [f'"""{response}"""' for response in responses] - responses = "\n\n".join(responses) + responses = '\n\n'.join(responses) - template = template.replace("{{responses}}", responses) + template = template.replace('{{responses}}', responses) return template def tools_function_calling_generation_template(template: str, tools_specs: str) -> str: - template = template.replace("{{TOOLS}}", tools_specs) + template = template.replace('{{TOOLS}}', tools_specs) return template diff --git a/backend/open_webui/utils/telemetry/constants.py b/backend/open_webui/utils/telemetry/constants.py index 6ef511f934..1f2102a86f 100644 --- a/backend/open_webui/utils/telemetry/constants.py +++ b/backend/open_webui/utils/telemetry/constants.py @@ -1,12 +1,12 @@ from opentelemetry.semconv.trace import SpanAttributes as _SpanAttributes # Span Tags -SPAN_DB_TYPE = "mysql" -SPAN_REDIS_TYPE = "redis" -SPAN_DURATION = "duration" -SPAN_SQL_STR = "sql" -SPAN_SQL_EXPLAIN = "explain" -SPAN_ERROR_TYPE = "error" +SPAN_DB_TYPE = 'mysql' +SPAN_REDIS_TYPE = 'redis' +SPAN_DURATION = 'duration' +SPAN_SQL_STR = 'sql' +SPAN_SQL_EXPLAIN = 'explain' +SPAN_ERROR_TYPE = 'error' class SpanAttributes(_SpanAttributes): @@ -14,13 +14,13 @@ class SpanAttributes(_SpanAttributes): Span Attributes """ - DB_INSTANCE = "db.instance" - DB_TYPE = "db.type" - DB_IP = "db.ip" - DB_PORT = "db.port" - ERROR_KIND = "error.kind" - ERROR_OBJECT = "error.object" - ERROR_MESSAGE = "error.message" - RESULT_CODE = "result.code" - RESULT_MESSAGE = "result.message" - RESULT_ERRORS = "result.errors" + DB_INSTANCE = 'db.instance' + DB_TYPE = 'db.type' + DB_IP = 'db.ip' + DB_PORT = 'db.port' + ERROR_KIND = 'error.kind' + ERROR_OBJECT = 'error.object' + ERROR_MESSAGE = 'error.message' + RESULT_CODE = 'result.code' + RESULT_MESSAGE = 'result.message' + RESULT_ERRORS = 'result.errors' diff --git a/backend/open_webui/utils/telemetry/instrumentors.py b/backend/open_webui/utils/telemetry/instrumentors.py index 25cd027d0e..394e7178d6 100644 --- a/backend/open_webui/utils/telemetry/instrumentors.py +++ b/backend/open_webui/utils/telemetry/instrumentors.py @@ -38,7 +38,7 @@ def requests_hook(span: Span, request: PreparedRequest): Http Request Hook """ - span.update_name(f"{request.method} {request.url}") + span.update_name(f'{request.method} {request.url}') span.set_attributes( attributes={ SpanAttributes.HTTP_URL: request.url, @@ -70,8 +70,8 @@ def redis_request_hook(span: Span, instance: Union[Redis | RedisCluster], args, # - redis.cluster.RedisCluster # Instead of checking the type, we check if the instance has a nodes_manager attribute. try: - db = "" - if hasattr(instance, "nodes_manager"): + db = '' + if hasattr(instance, 'nodes_manager'): default_node = instance.nodes_manager.default_node if not default_node: return @@ -79,17 +79,17 @@ def redis_request_hook(span: Span, instance: Union[Redis | RedisCluster], args, port = default_node.port else: connection_kwargs: dict = instance.connection_pool.connection_kwargs - host = connection_kwargs.get("host") - port = connection_kwargs.get("port") - db = connection_kwargs.get("db") + host = connection_kwargs.get('host') + port = connection_kwargs.get('port') + db = connection_kwargs.get('db') span.set_attributes( { - SpanAttributes.DB_INSTANCE: f"{host}/{db}", - SpanAttributes.DB_NAME: f"{host}/{db}", + SpanAttributes.DB_INSTANCE: f'{host}/{db}', + SpanAttributes.DB_NAME: f'{host}/{db}', SpanAttributes.DB_TYPE: SPAN_REDIS_TYPE, SpanAttributes.DB_PORT: port, SpanAttributes.DB_IP: host, - SpanAttributes.DB_STATEMENT: " ".join([str(i) for i in args]), + SpanAttributes.DB_STATEMENT: ' '.join([str(i) for i in args]), SpanAttributes.DB_OPERATION: str(args[0]), } ) @@ -102,7 +102,7 @@ def httpx_request_hook(span: Span, request: RequestInfo): HTTPX Request Hook """ - span.update_name(f"{request.method.decode()} {str(request.url)}") + span.update_name(f'{request.method.decode()} {str(request.url)}') span.set_attributes( attributes={ SpanAttributes.HTTP_URL: str(request.url), @@ -117,11 +117,7 @@ def httpx_response_hook(span: Span, request: RequestInfo, response: ResponseInfo """ span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.status_code) - span.set_status( - StatusCode.ERROR - if response.status_code >= status.HTTP_400_BAD_REQUEST - else StatusCode.OK - ) + span.set_status(StatusCode.ERROR if response.status_code >= status.HTTP_400_BAD_REQUEST else StatusCode.OK) async def httpx_async_request_hook(span: Span, request: RequestInfo): @@ -132,9 +128,7 @@ async def httpx_async_request_hook(span: Span, request: RequestInfo): httpx_request_hook(span, request) -async def httpx_async_response_hook( - span: Span, request: RequestInfo, response: ResponseInfo -): +async def httpx_async_response_hook(span: Span, request: RequestInfo, response: ResponseInfo): """ Async Response Hook """ @@ -147,7 +141,7 @@ def aiohttp_request_hook(span: Span, request: TraceRequestStartParams): Aiohttp Request Hook """ - span.update_name(f"{request.method} {str(request.url)}") + span.update_name(f'{request.method} {str(request.url)}') span.set_attributes( attributes={ SpanAttributes.HTTP_URL: str(request.url), @@ -156,20 +150,14 @@ def aiohttp_request_hook(span: Span, request: TraceRequestStartParams): ) -def aiohttp_response_hook( - span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams] -): +def aiohttp_response_hook(span: Span, response: Union[TraceRequestExceptionParams, TraceRequestEndParams]): """ Aiohttp Response Hook """ if isinstance(response, TraceRequestEndParams): span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, response.response.status) - span.set_status( - StatusCode.ERROR - if response.response.status >= status.HTTP_400_BAD_REQUEST - else StatusCode.OK - ) + span.set_status(StatusCode.ERROR if response.response.status >= status.HTTP_400_BAD_REQUEST else StatusCode.OK) elif isinstance(response, TraceRequestExceptionParams): span.set_status(StatusCode.ERROR) span.set_attribute(SpanAttributes.ERROR_MESSAGE, str(response.exception)) @@ -191,9 +179,7 @@ def _instrument(self, **kwargs): instrument_fastapi(app=self.app) SQLAlchemyInstrumentor().instrument(engine=self.db_engine) RedisInstrumentor().instrument(request_hook=redis_request_hook) - RequestsInstrumentor().instrument( - request_hook=requests_hook, response_hook=response_hook - ) + RequestsInstrumentor().instrument(request_hook=requests_hook, response_hook=response_hook) LoggingInstrumentor().instrument() HTTPXClientInstrumentor().instrument( request_hook=httpx_request_hook, @@ -208,7 +194,7 @@ def _instrument(self, **kwargs): SystemMetricsInstrumentor().instrument() def _uninstrument(self, **kwargs): - if getattr(self, "instrumentors", None) is None: + if getattr(self, 'instrumentors', None) is None: return for instrumentor in self.instrumentors: instrumentor.uninstrument() diff --git a/backend/open_webui/utils/telemetry/logs.py b/backend/open_webui/utils/telemetry/logs.py index 00d3e28c07..e501c99cea 100644 --- a/backend/open_webui/utils/telemetry/logs.py +++ b/backend/open_webui/utils/telemetry/logs.py @@ -24,12 +24,12 @@ def setup_logging(): headers = [] if OTEL_LOGS_BASIC_AUTH_USERNAME and OTEL_LOGS_BASIC_AUTH_PASSWORD: - auth_string = f"{OTEL_LOGS_BASIC_AUTH_USERNAME}:{OTEL_LOGS_BASIC_AUTH_PASSWORD}" + auth_string = f'{OTEL_LOGS_BASIC_AUTH_USERNAME}:{OTEL_LOGS_BASIC_AUTH_PASSWORD}' auth_header = b64encode(auth_string.encode()).decode() - headers = [("authorization", f"Basic {auth_header}")] + headers = [('authorization', f'Basic {auth_header}')] resource = Resource.create(attributes={SERVICE_NAME: OTEL_SERVICE_NAME}) - if OTEL_LOGS_OTLP_SPAN_EXPORTER == "http": + if OTEL_LOGS_OTLP_SPAN_EXPORTER == 'http': exporter = HttpOTLPLogExporter( endpoint=OTEL_LOGS_EXPORTER_OTLP_ENDPOINT, headers=headers, diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index cafe779d80..4c43de3342 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/backend/open_webui/utils/telemetry/metrics.py @@ -44,30 +44,25 @@ OTEL_METRICS_BASIC_AUTH_PASSWORD, OTEL_METRICS_OTLP_SPAN_EXPORTER, OTEL_METRICS_EXPORTER_OTLP_INSECURE, + OTEL_METRICS_EXPORT_INTERVAL_MILLIS, ) from open_webui.models.users import Users -_EXPORT_INTERVAL_MILLIS = 10_000 # 10 seconds - def _build_meter_provider(resource: Resource) -> MeterProvider: """Return a configured MeterProvider.""" headers = [] if OTEL_METRICS_BASIC_AUTH_USERNAME and OTEL_METRICS_BASIC_AUTH_PASSWORD: - auth_string = ( - f"{OTEL_METRICS_BASIC_AUTH_USERNAME}:{OTEL_METRICS_BASIC_AUTH_PASSWORD}" - ) + auth_string = f'{OTEL_METRICS_BASIC_AUTH_USERNAME}:{OTEL_METRICS_BASIC_AUTH_PASSWORD}' auth_header = b64encode(auth_string.encode()).decode() - headers = [("authorization", f"Basic {auth_header}")] + headers = [('authorization', f'Basic {auth_header}')] # Periodic reader pushes metrics over OTLP/gRPC to collector - if OTEL_METRICS_OTLP_SPAN_EXPORTER == "http": + if OTEL_METRICS_OTLP_SPAN_EXPORTER == 'http': readers: List[PeriodicExportingMetricReader] = [ PeriodicExportingMetricReader( - OTLPHttpMetricExporter( - endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, headers=headers - ), - export_interval_millis=_EXPORT_INTERVAL_MILLIS, + OTLPHttpMetricExporter(endpoint=OTEL_METRICS_EXPORTER_OTLP_ENDPOINT, headers=headers), + export_interval_millis=OTEL_METRICS_EXPORT_INTERVAL_MILLIS, ) ] else: @@ -78,28 +73,28 @@ def _build_meter_provider(resource: Resource) -> MeterProvider: insecure=OTEL_METRICS_EXPORTER_OTLP_INSECURE, headers=headers, ), - export_interval_millis=_EXPORT_INTERVAL_MILLIS, + export_interval_millis=OTEL_METRICS_EXPORT_INTERVAL_MILLIS, ) ] # Optional view to limit cardinality: drop user-agent etc. views: List[View] = [ View( - instrument_name="http.server.duration", - attribute_keys=["http.method", "http.route", "http.status_code"], + instrument_name='http.server.duration', + attribute_keys=['http.method', 'http.route', 'http.status_code'], ), View( - instrument_name="http.server.requests", - attribute_keys=["http.method", "http.route", "http.status_code"], + instrument_name='http.server.requests', + attribute_keys=['http.method', 'http.route', 'http.status_code'], ), View( - instrument_name="webui.users.total", + instrument_name='webui.users.total', ), View( - instrument_name="webui.users.active", + instrument_name='webui.users.active', ), View( - instrument_name="webui.users.active.today", + instrument_name='webui.users.active.today', ), ] @@ -119,14 +114,14 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None: # Instruments request_counter = meter.create_counter( - name="http.server.requests", - description="Counts the total number of inbound HTTP requests.", - unit="1", + name='http.server.requests', + description='Counts the total number of inbound HTTP requests.', + unit='1', ) duration_histogram = meter.create_histogram( - name="http.server.duration", - description="Measures the duration of inbound HTTP requests.", - unit="ms", + name='http.server.duration', + description='Measures the duration of inbound HTTP requests.', + unit='ms', ) def observe_active_users( @@ -151,16 +146,16 @@ def observe_total_registered_users( ] meter.create_observable_gauge( - name="webui.users.total", - description="Total number of registered users", - unit="users", + name='webui.users.total', + description='Total number of registered users', + unit='users', callbacks=[observe_total_registered_users], ) meter.create_observable_gauge( - name="webui.users.active", - description="Number of currently active users", - unit="users", + name='webui.users.active', + description='Number of currently active users', + unit='users', callbacks=[observe_active_users], ) @@ -170,21 +165,21 @@ def observe_users_active_today( return [metrics.Observation(value=Users.get_num_users_active_today())] meter.create_observable_gauge( - name="webui.users.active.today", - description="Number of users active since midnight today", - unit="users", + name='webui.users.active.today', + description='Number of users active since midnight today', + unit='users', callbacks=[observe_users_active_today], ) # FastAPI middleware - @app.middleware("http") + @app.middleware('http') async def _metrics_middleware(request: Request, call_next): start_time = time.perf_counter() status_code = None try: response = await call_next(request) - status_code = getattr(response, "status_code", 500) + status_code = getattr(response, 'status_code', 500) return response except Exception: status_code = 500 @@ -193,13 +188,13 @@ async def _metrics_middleware(request: Request, call_next): elapsed_ms = (time.perf_counter() - start_time) * 1000.0 # Route template e.g. "/items/{item_id}" instead of real path. - route = request.scope.get("route") - route_path = getattr(route, "path", request.url.path) + route = request.scope.get('route') + route_path = getattr(route, 'path', request.url.path) attrs: Dict[str, str | int] = { - "http.method": request.method, - "http.route": route_path, - "http.status_code": status_code, + 'http.method': request.method, + 'http.route': route_path, + 'http.status_code': status_code, } request_counter.add(1, attrs) diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py index 36294b4e56..744dced2d0 100644 --- a/backend/open_webui/utils/telemetry/setup.py +++ b/backend/open_webui/utils/telemetry/setup.py @@ -34,12 +34,12 @@ def setup(app: FastAPI, db_engine: Engine): # Add basic auth header only if both username and password are not empty headers = [] if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD: - auth_string = f"{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}" + auth_string = f'{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}' auth_header = b64encode(auth_string.encode()).decode() - headers = [("authorization", f"Basic {auth_header}")] + headers = [('authorization', f'Basic {auth_header}')] # otlp export - if OTEL_OTLP_SPAN_EXPORTER == "http": + if OTEL_OTLP_SPAN_EXPORTER == 'http': exporter = HttpOTLPSpanExporter( endpoint=OTEL_EXPORTER_OTLP_ENDPOINT, headers=headers, diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index e525a8284c..210ff24085 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1,3 +1,4 @@ +import base64 import inspect import logging import re @@ -44,6 +45,7 @@ from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL from open_webui.env import ( AIOHTTP_CLIENT_TIMEOUT, + AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA, AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, ENABLE_FORWARD_USER_INFO_HEADERS, @@ -79,6 +81,7 @@ query_knowledge_bases, search_knowledge_files, query_knowledge_files, + list_knowledge, view_file, view_knowledge_file, view_skill, @@ -89,9 +92,9 @@ log = logging.getLogger(__name__) -def get_async_tool_function_and_apply_extra_params( - function: Callable, extra_params: dict -) -> Callable[..., Awaitable]: +# Let no function be called without need, and let what +# it yields justify the cost of running it. +def get_async_tool_function_and_apply_extra_params(function: Callable, extra_params: dict) -> Callable[..., Awaitable]: sig = inspect.signature(function) extra_params = {k: v for k, v in extra_params.items() if k in sig.parameters} partial_func = partial(function, **extra_params) @@ -106,9 +109,7 @@ def get_async_tool_function_and_apply_extra_params( # Keep remaining parameters parameters.append(parameter) - new_sig = inspect.Signature( - parameters=parameters, return_annotation=sig.return_annotation - ) + new_sig = inspect.Signature(parameters=parameters, return_annotation=sig.return_annotation) if inspect.iscoroutinefunction(function): # wrap the functools.partial as python-genai has trouble with it @@ -132,8 +133,8 @@ async def new_function(*args, **kwargs): def get_updated_tool_function(function: Callable, extra_params: dict): # Get the original function and merge updated params - __function__ = getattr(function, "__function__", None) - __extra_params__ = getattr(function, "__extra_params__", None) + __function__ = getattr(function, '__function__', None) + __extra_params__ = getattr(function, '__extra_params__', None) if __function__ is not None and __extra_params__ is not None: return get_async_tool_function_and_apply_extra_params( @@ -144,9 +145,7 @@ def get_updated_tool_function(function: Callable, extra_params: dict): return function -async def get_tools( - request: Request, tool_ids: list[str], user: UserModel, extra_params: dict -) -> dict[str, 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 tool_ids: return {} @@ -161,17 +160,17 @@ async def get_tools( if tool: # Check access control for local tools if ( - not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) and tool.user_id != user.id and not AccessGrants.has_access( user_id=user.id, - resource_type="tool", + resource_type='tool', resource_id=tool.id, - permission="read", + permission='read', user_group_ids=user_group_ids, ) ): - log.warning(f"Access denied to tool {tool_id} for user {user.id}") + log.warning(f'Access denied to tool {tool_id} for user {user.id}') continue module = request.app.state.TOOLS.get(tool_id, None) @@ -180,161 +179,146 @@ async def get_tools( request.app.state.TOOLS[tool_id] = module __user__ = { - **extra_params["__user__"], + **extra_params['__user__'], } # Set valves for the tool - if hasattr(module, "valves") and hasattr(module, "Valves"): + if hasattr(module, 'valves') and hasattr(module, 'Valves'): valves = Tools.get_tool_valves_by_id(tool_id) or {} module.valves = module.Valves(**valves) - if hasattr(module, "UserValves"): - __user__["valves"] = module.UserValves( # type: ignore + if hasattr(module, 'UserValves'): + __user__['valves'] = module.UserValves( # type: ignore **Tools.get_user_valves_by_id_and_user_id(tool_id, user.id) ) for spec in tool.specs: # TODO: Fix hack for OpenAI API # Some times breaks OpenAI but others don't. Leaving the comment - for val in spec.get("parameters", {}).get("properties", {}).values(): - if val.get("type") == "str": - val["type"] = "string" + for val in spec.get('parameters', {}).get('properties', {}).values(): + if val.get('type') == 'str': + val['type'] = 'string' # Remove internal reserved parameters (e.g. __id__, __user__) - spec["parameters"]["properties"] = { - key: val - for key, val in spec["parameters"]["properties"].items() - if not key.startswith("__") + spec['parameters']['properties'] = { + key: val for key, val in spec['parameters']['properties'].items() if not key.startswith('__') } # convert to function that takes only model params and inserts custom params - function_name = spec["name"] + function_name = spec['name'] tool_function = getattr(module, function_name) callable = get_async_tool_function_and_apply_extra_params( tool_function, { **extra_params, - "__id__": tool_id, - "__user__": __user__, + '__id__': tool_id, + '__user__': __user__, }, ) # TODO: Support Pydantic models as parameters - if callable.__doc__ and callable.__doc__.strip() != "": - s = re.split(":(param|return)", callable.__doc__, 1) - spec["description"] = s[0] + if callable.__doc__ and callable.__doc__.strip() != '': + s = re.split(':(param|return)', callable.__doc__, 1) + spec['description'] = s[0] else: - spec["description"] = function_name + spec['description'] = function_name tool_dict = { - "tool_id": tool_id, - "callable": callable, - "spec": spec, + 'tool_id': tool_id, + 'callable': callable, + 'spec': spec, # Misc info - "metadata": { - "file_handler": hasattr(module, "file_handler") - and module.file_handler, - "citation": hasattr(module, "citation") and module.citation, + 'metadata': { + 'file_handler': hasattr(module, 'file_handler') and module.file_handler, + 'citation': hasattr(module, 'citation') and module.citation, }, } # Handle function name collisions while function_name in tools_dict: - log.warning( - f"Tool {function_name} already exists in another tools!" - ) + log.warning(f'Tool {function_name} already exists in another tools!') # Prepend tool ID to function name - function_name = f"{tool_id}_{function_name}" + function_name = f'{tool_id}_{function_name}' tools_dict[function_name] = tool_dict else: - if tool_id.startswith("server:"): - splits = tool_id.split(":") + if tool_id.startswith('server:'): + splits = tool_id.split(':') if len(splits) == 2: - type = "openapi" + type = 'openapi' server_id = splits[1] elif len(splits) == 3: type = splits[1] server_id = splits[2] - server_id_splits = server_id.split("|") + server_id_splits = server_id.split('|') if len(server_id_splits) == 2: server_id = server_id_splits[0] - function_names = server_id_splits[1].split(",") - - if type == "openapi": + function_names = server_id_splits[1].split(',') + if type == 'openapi': tool_server_data = None for server in await get_tool_servers(request): - if server["id"] == server_id: + if server['id'] == server_id: tool_server_data = server break if tool_server_data is None: - log.warning(f"Tool server data not found for {server_id}") + log.warning(f'Tool server data not found for {server_id}') continue - tool_server_idx = tool_server_data.get("idx", 0) - tool_server_connection = ( - request.app.state.config.TOOL_SERVER_CONNECTIONS[ - tool_server_idx - ] - ) - - # Check access control for tool server - if not has_connection_access( - user, tool_server_connection, user_group_ids - ): + tool_server_idx = tool_server_data.get('idx', 0) + connections = request.app.state.config.TOOL_SERVER_CONNECTIONS + if tool_server_idx >= len(connections): log.warning( - f"Access denied to tool server {server_id} for user {user.id}" + f'Tool server index {tool_server_idx} out of range ' + f'(have {len(connections)} connections), skipping server {server_id}' ) continue + tool_server_connection = connections[tool_server_idx] - specs = tool_server_data.get("specs", []) - function_name_filter_list = tool_server_connection.get( - "config", {} - ).get("function_name_filter_list", "") + # Check access control for tool server + if not has_connection_access(user, tool_server_connection, user_group_ids): + log.warning(f'Access denied to tool server {server_id} for user {user.id}') + continue + + specs = tool_server_data.get('specs', []) + function_name_filter_list = tool_server_connection.get('config', {}).get( + 'function_name_filter_list', '' + ) if isinstance(function_name_filter_list, str): - function_name_filter_list = function_name_filter_list.split(",") + function_name_filter_list = function_name_filter_list.split(',') for spec in specs: - function_name = spec["name"] + function_name = spec['name'] if function_name_filter_list: - if not is_string_allowed( - function_name, function_name_filter_list - ): + if not is_string_allowed(function_name, function_name_filter_list): # Skip this function continue - auth_type = tool_server_connection.get("auth_type", "bearer") + auth_type = tool_server_connection.get('auth_type', 'bearer') cookies = {} headers = { - "Content-Type": "application/json", + 'Content-Type': 'application/json', } - if auth_type == "bearer": - headers["Authorization"] = ( - f"Bearer {tool_server_connection.get('key', '')}" - ) - elif auth_type == "none": + if auth_type == 'bearer': + headers['Authorization'] = f'Bearer {tool_server_connection.get("key", "")}' + elif auth_type == 'none': # No authentication pass - elif auth_type == "session": + elif auth_type == 'session': cookies = request.cookies - headers["Authorization"] = ( - f"Bearer {request.state.token.credentials}" - ) - elif auth_type == "system_oauth": + headers['Authorization'] = f'Bearer {request.state.token.credentials}' + elif auth_type == 'system_oauth': cookies = request.cookies - oauth_token = extra_params.get("__oauth_token__", None) + oauth_token = extra_params.get('__oauth_token__', None) if oauth_token: - headers["Authorization"] = ( - f"Bearer {oauth_token.get('access_token', '')}" - ) + headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' - connection_headers = tool_server_connection.get("headers", None) + connection_headers = tool_server_connection.get('headers', None) if connection_headers and isinstance(connection_headers, dict): for key, value in connection_headers.items(): headers[key] = value @@ -342,22 +326,16 @@ async def get_tools( # Add user info headers if enabled if ENABLE_FORWARD_USER_INFO_HEADERS and user: headers = include_user_info_headers(headers, user) - metadata = extra_params.get("__metadata__", {}) - if metadata and metadata.get("chat_id"): - headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = ( - metadata.get("chat_id") - ) - if metadata and metadata.get("message_id"): - headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = ( - metadata.get("message_id") - ) + metadata = extra_params.get('__metadata__', {}) + if metadata and metadata.get('chat_id'): + headers[FORWARD_SESSION_INFO_HEADER_CHAT_ID] = metadata.get('chat_id') + if metadata and metadata.get('message_id'): + headers[FORWARD_SESSION_INFO_HEADER_MESSAGE_ID] = metadata.get('message_id') - def make_tool_function( - function_name, tool_server_data, headers - ): + def make_tool_function(function_name, tool_server_data, headers): async def tool_function(**kwargs): return await execute_tool_server( - url=tool_server_data["url"], + url=tool_server_data['url'], headers=headers, cookies=cookies, name=function_name, @@ -367,9 +345,7 @@ async def tool_function(**kwargs): return tool_function - tool_function = make_tool_function( - function_name, tool_server_data, headers - ) + tool_function = make_tool_function(function_name, tool_server_data, headers) callable = get_async_tool_function_and_apply_extra_params( tool_function, @@ -377,20 +353,18 @@ async def tool_function(**kwargs): ) tool_dict = { - "tool_id": tool_id, - "callable": callable, - "spec": clean_openai_tool_schema(spec), + 'tool_id': tool_id, + 'callable': callable, + 'spec': clean_openai_tool_schema(spec), # Misc info - "type": "external", + 'type': 'external', } # Handle function name collisions while function_name in tools_dict: - log.warning( - f"Tool {function_name} already exists in another tools!" - ) + log.warning(f'Tool {function_name} already exists in another tools!') # Prepend server ID to function name - function_name = f"{server_id}_{function_name}" + function_name = f'{server_id}_{function_name}' tools_dict[function_name] = tool_dict @@ -414,37 +388,38 @@ def get_builtin_tools( # Helper to get model capabilities (defaults to True if not specified) def get_model_capability(name: str, default: bool = True) -> bool: - return (model.get("info", {}).get("meta", {}).get("capabilities") or {}).get( - name, default - ) + return (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get(name, default) # Helper to check if a builtin tool category is enabled via meta.builtinTools # Defaults to True if not specified (backward compatible) def is_builtin_tool_enabled(category: str) -> bool: - builtin_tools = model.get("info", {}).get("meta", {}).get("builtinTools", {}) + builtin_tools = model.get('info', {}).get('meta', {}).get('builtinTools', {}) return builtin_tools.get(category, True) # Time utilities - available for date calculations - if is_builtin_tool_enabled("time"): + if is_builtin_tool_enabled('time'): builtin_functions.extend([get_current_timestamp, calculate_timestamp]) # 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", []) + 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") + folder_knowledge = extra_params.get('__metadata__', {}).get('folder_knowledge') if folder_knowledge: model_knowledge = list(model_knowledge or []) + list(folder_knowledge) - if is_builtin_tool_enabled("knowledge"): + if is_builtin_tool_enabled('knowledge'): if model_knowledge: - # Model has attached knowledge - only allow semantic search within it + # Model has attached knowledge - provide discovery, search and semantic tools + builtin_functions.append(list_knowledge) + builtin_functions.append(search_knowledge_files) builtin_functions.append(query_knowledge_files) - knowledge_types = {item.get("type") for item in model_knowledge} - if "file" in knowledge_types or "collection" in knowledge_types: + knowledge_types = {item.get('type') for item in model_knowledge} + if 'file' in knowledge_types or 'collection' in knowledge_types: builtin_functions.append(view_file) - if "note" in knowledge_types: + builtin_functions.append(view_knowledge_file) + if 'note' in knowledge_types: builtin_functions.append(view_note) else: # No model knowledge - allow full KB browsing @@ -460,11 +435,11 @@ def is_builtin_tool_enabled(category: str) -> bool: ) # Chats tools - search and fetch user's chat history - if is_builtin_tool_enabled("chats"): + if is_builtin_tool_enabled('chats'): builtin_functions.extend([search_chats, view_chat]) # Add memory tools if builtin category enabled AND enabled for this chat - if is_builtin_tool_enabled("memory") and features.get("memory"): + if is_builtin_tool_enabled('memory') and (features.get('memory') or get_model_capability('memory', False)): builtin_functions.extend( [ search_memories, @@ -477,50 +452,44 @@ def is_builtin_tool_enabled(category: str) -> bool: # Add web search tools if builtin category enabled AND enabled globally AND model has web_search capability if ( - is_builtin_tool_enabled("web_search") - and getattr(request.app.state.config, "ENABLE_WEB_SEARCH", False) - and get_model_capability("web_search") - and features.get("web_search") + is_builtin_tool_enabled('web_search') + and getattr(request.app.state.config, 'ENABLE_WEB_SEARCH', False) + and get_model_capability('web_search') + and features.get('web_search') ): 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 if ( - is_builtin_tool_enabled("image_generation") - and getattr(request.app.state.config, "ENABLE_IMAGE_GENERATION", False) - and get_model_capability("image_generation") - and features.get("image_generation") + is_builtin_tool_enabled('image_generation') + and getattr(request.app.state.config, 'ENABLE_IMAGE_GENERATION', False) + and get_model_capability('image_generation') + and features.get('image_generation') ): builtin_functions.append(generate_image) if ( - is_builtin_tool_enabled("image_generation") - and getattr(request.app.state.config, "ENABLE_IMAGE_EDIT", False) - and get_model_capability("image_generation") - and features.get("image_generation") + is_builtin_tool_enabled('image_generation') + and getattr(request.app.state.config, 'ENABLE_IMAGE_EDIT', False) + and get_model_capability('image_generation') + and features.get('image_generation') ): builtin_functions.append(edit_image) # Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability if ( - is_builtin_tool_enabled("code_interpreter") - and getattr(request.app.state.config, "ENABLE_CODE_INTERPRETER", True) - and get_model_capability("code_interpreter") - and features.get("code_interpreter") + is_builtin_tool_enabled('code_interpreter') + and getattr(request.app.state.config, 'ENABLE_CODE_INTERPRETER', True) + and get_model_capability('code_interpreter') + and features.get('code_interpreter') ): builtin_functions.append(execute_code) # Notes tools - search, view, create, and update user's notes (if builtin category enabled AND notes enabled globally) - if is_builtin_tool_enabled("notes") and getattr( - request.app.state.config, "ENABLE_NOTES", False - ): - builtin_functions.extend( - [search_notes, view_note, write_note, replace_note_content] - ) + if is_builtin_tool_enabled('notes') and getattr(request.app.state.config, 'ENABLE_NOTES', False): + builtin_functions.extend([search_notes, view_note, write_note, replace_note_content]) # Channels tools - search channels and messages (if builtin category enabled AND channels enabled globally) - if is_builtin_tool_enabled("channels") and getattr( - request.app.state.config, "ENABLE_CHANNELS", False - ): + if is_builtin_tool_enabled('channels') and getattr(request.app.state.config, 'ENABLE_CHANNELS', False): builtin_functions.extend( [ search_channels, @@ -531,21 +500,21 @@ def is_builtin_tool_enabled(category: str) -> bool: ) # Skills tools - view_skill allows model to load full skill instructions on demand - if extra_params.get("__skill_ids__"): + if extra_params.get('__skill_ids__'): builtin_functions.append(view_skill) for func in builtin_functions: callable = get_async_tool_function_and_apply_extra_params( func, { - "__request__": request, - "__user__": extra_params.get("__user__", {}), - "__event_emitter__": extra_params.get("__event_emitter__"), - "__event_call__": extra_params.get("__event_call__"), - "__metadata__": extra_params.get("__metadata__"), - "__chat_id__": extra_params.get("__chat_id__"), - "__message_id__": extra_params.get("__message_id__"), - "__model_knowledge__": model_knowledge, + '__request__': request, + '__user__': extra_params.get('__user__', {}), + '__event_emitter__': extra_params.get('__event_emitter__'), + '__event_call__': extra_params.get('__event_call__'), + '__metadata__': extra_params.get('__metadata__'), + '__chat_id__': extra_params.get('__chat_id__'), + '__message_id__': extra_params.get('__message_id__'), + '__model_knowledge__': model_knowledge, }, ) @@ -555,10 +524,10 @@ def is_builtin_tool_enabled(category: str) -> bool: spec = clean_openai_tool_schema(spec) tools_dict[func.__name__] = { - "tool_id": f"builtin:{func.__name__}", - "callable": callable, - "spec": spec, - "type": "builtin", + 'tool_id': f'builtin:{func.__name__}', + 'callable': callable, + 'spec': spec, + 'type': 'builtin', } return tools_dict @@ -576,18 +545,18 @@ def parse_description(docstring: str | None) -> str: """ if not docstring: - return "" + return '' - lines = [line.strip() for line in docstring.strip().split("\n")] + lines = [line.strip() for line in docstring.strip().split('\n')] description_lines: list[str] = [] for line in lines: - if re.match(r":param", line) or re.match(r":return", line): + if re.match(r':param', line) or re.match(r':return', line): break description_lines.append(line) - return "\n".join(description_lines) + return '\n'.join(description_lines) def parse_docstring(docstring): @@ -604,7 +573,7 @@ def parse_docstring(docstring): return {} # Regex to match `:param name: description` format - param_pattern = re.compile(r":param (\w+):\s*(.+)") + param_pattern = re.compile(r':param (\w+):\s*(.+)') param_descriptions = {} for line in docstring.splitlines(): @@ -612,7 +581,7 @@ def parse_docstring(docstring): if not match: continue param_name, param_description = match.groups() - if param_name.startswith("__"): + if param_name.startswith('__'): continue param_descriptions[param_name] = param_description @@ -665,27 +634,27 @@ def clean_properties(schema: dict): if not isinstance(schema, dict): return - if "anyOf" in schema: - non_null_types = [t for t in schema["anyOf"] if t.get("type") != "null"] + if 'anyOf' in schema: + non_null_types = [t for t in schema['anyOf'] if t.get('type') != 'null'] if len(non_null_types) == 1: schema.update(non_null_types[0]) - del schema["anyOf"] + del schema['anyOf'] else: - schema["anyOf"] = non_null_types + schema['anyOf'] = non_null_types - if "default" in schema and schema["default"] is None: - del schema["default"] + if 'default' in schema and schema['default'] is None: + del schema['default'] # fix missing type - if "type" not in schema and "anyOf" not in schema and "properties" not in schema: - schema["type"] = "string" + if 'type' not in schema and 'anyOf' not in schema and 'properties' not in schema: + schema['type'] = 'string' - if "properties" in schema: - for prop_name, prop_schema in schema["properties"].items(): + if 'properties' in schema: + for prop_name, prop_schema in schema['properties'].items(): clean_properties(prop_schema) - if "items" in schema: - clean_properties(schema["items"]) + if 'items' in schema: + clean_properties(schema['items']) def clean_openai_tool_schema(spec: dict) -> dict: @@ -693,8 +662,8 @@ def clean_openai_tool_schema(spec: dict) -> dict: cleaned_spec = copy.deepcopy(spec) - if "parameters" in cleaned_spec: - clean_properties(cleaned_spec["parameters"]) + if 'parameters' in cleaned_spec: + clean_properties(cleaned_spec['parameters']) return cleaned_spec @@ -703,12 +672,8 @@ def get_functions_from_tool(tool: object) -> list[Callable]: return [ getattr(tool, func) for func in dir(tool) - if callable( - getattr(tool, func) - ) # checks if the attribute is callable (a method or function). - and not func.startswith( - "_" - ) # filters out internal methods (starting with _) and special (dunder) methods. + if callable(getattr(tool, func)) # checks if the attribute is callable (a method or function). + and not func.startswith('_') # filters out internal methods (starting with _) and special (dunder) methods. and not inspect.isclass( getattr(tool, func) ) # ensures that the callable is not a class itself, just a method or function. @@ -716,14 +681,10 @@ def get_functions_from_tool(tool: object) -> list[Callable]: def get_tool_specs(tool_module: object) -> list[dict]: - function_models = map( - convert_function_to_pydantic_model, get_functions_from_tool(tool_module) - ) + function_models = map(convert_function_to_pydantic_model, get_functions_from_tool(tool_module)) specs = [ - clean_openai_tool_schema( - convert_pydantic_model_to_openai_function_spec(function_model) - ) + clean_openai_tool_schema(convert_pydantic_model_to_openai_function_spec(function_model)) for function_model in function_models ] @@ -737,9 +698,9 @@ def resolve_schema(schema, components): if not schema: return {} - if "$ref" in schema: - ref_path = schema["$ref"] - ref_parts = ref_path.strip("#/").split("/") + if '$ref' in schema: + ref_path = schema['$ref'] + ref_parts = ref_path.strip('#/').split('/') resolved = components for part in ref_parts[1:]: # Skip the initial 'components' resolved = resolved.get(part, {}) @@ -748,14 +709,12 @@ def resolve_schema(schema, components): 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 - ) + if 'properties' in resolved_schema: + for prop, prop_schema in resolved_schema['properties'].items(): + resolved_schema['properties'][prop] = resolve_schema(prop_schema, components) - if "items" in resolved_schema: - resolved_schema["items"] = resolve_schema(resolved_schema["items"], components) + if 'items' in resolved_schema: + resolved_schema['items'] = resolve_schema(resolved_schema['items'], components) return resolved_schema @@ -772,75 +731,60 @@ def convert_openapi_to_tool_payload(openapi_spec): """ tool_payload = [] - for path, methods in openapi_spec.get("paths", {}).items(): + for path, methods in openapi_spec.get('paths', {}).items(): for method, operation in methods.items(): - if operation.get("operationId"): + if operation.get('operationId'): tool = { - "name": operation.get("operationId"), - "description": operation.get( - "description", - operation.get("summary", "No description available."), + 'name': operation.get('operationId'), + 'description': operation.get( + 'description', + operation.get('summary', 'No description available.'), ), - "parameters": {"type": "object", "properties": {}, "required": []}, + 'parameters': {'type': 'object', 'properties': {}, 'required': []}, } - for param in operation.get("parameters", []): - param_name = param.get("name") + for param in operation.get('parameters', []): + param_name = param.get('name') if not param_name: continue - param_schema = param.get("schema", {}) - description = param_schema.get("description", "") + param_schema = param.get('schema', {}) + description = param_schema.get('description', '') if not description: - description = param.get("description") or "" - if param_schema.get("enum") and isinstance( - param_schema.get("enum"), list - ): - description += ( - f". Possible values: {', '.join(param_schema.get('enum'))}" - ) + description = param.get('description') or '' + if param_schema.get('enum') and isinstance(param_schema.get('enum'), list): + description += f'. Possible values: {", ".join(param_schema.get("enum"))}' param_property = { - "type": param_schema.get("type") or "string", - "description": description, + 'type': param_schema.get('type') or 'string', + 'description': description, } # Include items property for array types (required by OpenAI) - if param_schema.get("type") == "array" and "items" in param_schema: - param_property["items"] = param_schema["items"] + if param_schema.get('type') == 'array' and 'items' in param_schema: + param_property['items'] = param_schema['items'] # Filter out None values to prevent schema validation errors - param_property = { - k: v for k, v in param_property.items() if v is not None - } + param_property = {k: v for k, v in param_property.items() if v is not None} - tool["parameters"]["properties"][param_name] = param_property - if param.get("required"): - tool["parameters"]["required"].append(param_name) + tool['parameters']['properties'][param_name] = param_property + if param.get('required'): + tool['parameters']['required'].append(param_name) # Extract and resolve requestBody if available - request_body = operation.get("requestBody") + request_body = operation.get('requestBody') if request_body: - content = request_body.get("content", {}) - json_schema = content.get("application/json", {}).get("schema") + content = request_body.get('content', {}) + json_schema = content.get('application/json', {}).get('schema') if json_schema: - resolved_schema = resolve_schema( - json_schema, openapi_spec.get("components", {}) - ) + resolved_schema = resolve_schema(json_schema, openapi_spec.get('components', {})) - if resolved_schema.get("properties"): - tool["parameters"]["properties"].update( - resolved_schema["properties"] - ) - if "required" in resolved_schema: - tool["parameters"]["required"] = list( - set( - tool["parameters"]["required"] - + resolved_schema["required"] - ) + if resolved_schema.get('properties'): + tool['parameters']['properties'].update(resolved_schema['properties']) + if 'required' in resolved_schema: + tool['parameters']['required'] = list( + set(tool['parameters']['required'] + resolved_schema['required']) ) - elif resolved_schema.get("type") == "array": - tool["parameters"] = ( - resolved_schema # special case for array - ) + elif resolved_schema.get('type') == 'array': + tool['parameters'] = resolved_schema # special case for array tool_payload.append(tool) @@ -848,14 +792,10 @@ def convert_openapi_to_tool_payload(openapi_spec): async def set_tool_servers(request: Request): - request.app.state.TOOL_SERVERS = await get_tool_servers_data( - request.app.state.config.TOOL_SERVER_CONNECTIONS - ) + request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS) if request.app.state.redis is not None: - await request.app.state.redis.set( - "tool_servers", json.dumps(request.app.state.TOOL_SERVERS) - ) + await request.app.state.redis.set('tool_servers', json.dumps(request.app.state.TOOL_SERVERS)) return request.app.state.TOOL_SERVERS @@ -864,10 +804,10 @@ async def get_tool_servers(request: Request): tool_servers = [] if request.app.state.redis is not None: try: - tool_servers = json.loads(await request.app.state.redis.get("tool_servers")) + tool_servers = json.loads(await request.app.state.redis.get('tool_servers')) request.app.state.TOOL_SERVERS = tool_servers except Exception as e: - log.error(f"Error fetching tool_servers from Redis: {e}") + log.error(f'Error fetching tool_servers from Redis: {e}') if not tool_servers: tool_servers = await set_tool_servers(request) @@ -882,19 +822,52 @@ async def get_terminal_cwd( ) -> Optional[str]: """Fetch the current working directory from a terminal server.""" try: - cwd_url = f"{base_url.rstrip('/')}/files/cwd" + cwd_url = f'{base_url.rstrip("/")}/files/cwd' async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=5), trust_env=True, ) as session: - async with session.get( - cwd_url, headers=headers, cookies=cookies or {} - ) as resp: + async with session.get(cwd_url, headers=headers, cookies=cookies or {}) as resp: if resp.status == 200: data = await resp.json() - return data.get("cwd") + return data.get('cwd') except Exception as e: - log.debug(f"Failed to fetch terminal CWD: {e}") + log.debug(f'Failed to fetch terminal CWD: {e}') + return None + + +async def get_terminal_system_prompt( + base_url: str, + headers: dict, + cookies: Optional[dict] = None, +) -> Optional[str]: + """Fetch the system prompt from a terminal server. + + Checks ``/api/config`` for the ``system`` feature flag first; + only fetches ``/system`` if the flag is present. Returns *None* + silently when the server doesn't support the endpoint. + """ + base = base_url.rstrip('/') + try: + async with aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=3), + trust_env=True, + ) as session: + # 1. Check feature flag + async with session.get(f'{base}/api/config') as resp: + if resp.status != 200: + return None + config = await resp.json() + if not config.get('features', {}).get('system'): + return None + + # 2. Fetch system prompt + async with session.get(f'{base}/system', headers=headers, cookies=cookies or {}) as resp: + if resp.status == 200: + data = await resp.json() + return data.get('prompt') + except Exception as e: + log.debug(f'Failed to fetch terminal system prompt: {e}') return None @@ -906,33 +879,58 @@ async def set_terminal_servers(request: Request): # Terminal connections store id/name at top level; translate to info dict server_configs = [] for connection in connections: - if not connection.get("url"): + if not connection.get('url'): continue - enabled = connection.get("enabled", True) + 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}' server_configs.append( { - "url": connection.get("url", ""), - "key": connection.get("key", ""), - "auth_type": connection.get("auth_type", "bearer"), - "path": connection.get("path", "/openapi.json"), - "spec_type": "url", + 'url': base_url, + 'key': connection.get('key', ''), + 'auth_type': connection.get('auth_type', 'bearer'), + 'path': connection.get('path', '/openapi.json'), + 'spec_type': 'url', # get_tool_servers_data reads config.enable to filter active servers - "config": {"enable": enabled}, - "info": { - "id": connection.get("id", ""), - "name": connection.get("name", ""), + 'config': {'enable': enabled}, + 'info': { + 'id': connection.get('id', ''), + 'name': connection.get('name', ''), }, } ) request.app.state.TERMINAL_SERVERS = await get_tool_servers_data(server_configs) + # Fetch system prompts concurrently (runs at cache time, not per-request) + connections_by_id = {c.get('id'): c for c in connections if c.get('id')} + + async def _fetch_system_prompt(server): + connection = connections_by_id.get(server.get('id')) + if not connection: + return + headers = {} + if connection.get('auth_type', 'bearer') == 'bearer': + headers['Authorization'] = f'Bearer {connection.get("key", "")}' + prompt = await get_terminal_system_prompt(server['url'], headers) + if prompt: + server['system_prompt'] = prompt + + await asyncio.gather( + *[_fetch_system_prompt(s) for s in request.app.state.TERMINAL_SERVERS], + return_exceptions=True, + ) + if request.app.state.redis is not None: - await request.app.state.redis.set( - "terminal_servers", json.dumps(request.app.state.TERMINAL_SERVERS) - ) + await request.app.state.redis.set('terminal_servers', json.dumps(request.app.state.TERMINAL_SERVERS)) return request.app.state.TERMINAL_SERVERS @@ -942,12 +940,10 @@ async def get_terminal_servers(request: Request): terminal_servers = [] if request.app.state.redis is not None: try: - terminal_servers = json.loads( - await request.app.state.redis.get("terminal_servers") - ) + terminal_servers = json.loads(await request.app.state.redis.get('terminal_servers')) request.app.state.TERMINAL_SERVERS = terminal_servers except Exception as e: - log.error(f"Error fetching terminal_servers from Redis: {e}") + log.error(f'Error fetching terminal_servers from Redis: {e}') if not terminal_servers: terminal_servers = await set_terminal_servers(request) @@ -960,7 +956,7 @@ async def get_terminal_tools( terminal_id: str, user: UserModel, extra_params: dict, -) -> dict[str, dict]: +) -> tuple[dict[str, dict], Optional[str]]: """Resolve tools for a terminal server identified by terminal_id. - Finds the connection in TERMINAL_SERVER_CONNECTIONS @@ -969,64 +965,61 @@ async def get_terminal_tools( - Builds callables that route through the terminal proxy """ connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or [] - connection = next((c for c in connections if c.get("id") == terminal_id), None) + 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}") + log.warning(f'Terminal server not found: {terminal_id}') return {} user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user.id)} if not has_connection_access(user, connection, user_group_ids): - log.warning(f"Access denied to terminal {terminal_id} for user {user.id}") + log.warning(f'Access denied to terminal {terminal_id} for user {user.id}') return {} # 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 - ) + 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}") + log.warning(f'Terminal server spec not found for {terminal_id}') return {} - specs = server_data.get("specs", []) + specs = server_data.get('specs', []) if not specs: return {} # Build auth headers - auth_type = connection.get("auth_type", "bearer") + auth_type = connection.get('auth_type', 'bearer') cookies = {} - headers = {"Content-Type": "application/json", "X-User-Id": user.id} + headers = {'Content-Type': 'application/json', 'X-User-Id': user.id} - if auth_type == "bearer": - headers["Authorization"] = f"Bearer {connection.get('key', '')}" - elif auth_type == "session": + if auth_type == 'bearer': + headers['Authorization'] = f'Bearer {connection.get("key", "")}' + elif auth_type == 'session': cookies = request.cookies - headers["Authorization"] = f"Bearer {request.state.token.credentials}" - elif auth_type == "system_oauth": + headers['Authorization'] = f'Bearer {request.state.token.credentials}' + elif auth_type == 'system_oauth': cookies = request.cookies - oauth_token = extra_params.get("__oauth_token__", None) + oauth_token = extra_params.get('__oauth_token__', None) if oauth_token: - headers["Authorization"] = f"Bearer {oauth_token.get('access_token', '')}" + headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}' # auth_type == "none": no Authorization header - terminal_cwd = await get_terminal_cwd(connection.get("url", ""), headers, cookies) + system_prompt = server_data.get('system_prompt') + terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies) tools_dict = {} for spec in specs: - function_name = spec["name"] - - # Inject CWD into run_command description + function_name = spec['name'] tool_spec = clean_openai_tool_schema(spec) - if function_name == "run_command" and terminal_cwd: - tool_spec["description"] = ( - tool_spec.get("description", "") - + f"\n\nThe current working directory is: {terminal_cwd}" + + if function_name == 'run_command' and terminal_cwd: + tool_spec['description'] = ( + tool_spec.get('description', '') + f'\n\nThe current working directory is: {terminal_cwd}' ) def make_tool_function(fn_name, srv_data, hdrs, cks): async def tool_function(**kwargs): return await execute_tool_server( - url=srv_data["url"], + url=srv_data['url'], headers=hdrs, cookies=cks, name=fn_name, @@ -1040,19 +1033,19 @@ async def tool_function(**kwargs): callable = get_async_tool_function_and_apply_extra_params(tool_function, {}) tools_dict[function_name] = { - "tool_id": f"terminal:{terminal_id}", - "callable": callable, - "spec": tool_spec, - "type": "terminal", + 'tool_id': f'terminal:{terminal_id}', + 'callable': callable, + 'spec': tool_spec, + 'type': 'terminal', } - return tools_dict + return tools_dict, system_prompt async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, Any]: _headers = { - "Accept": "application/json", - "Content-Type": "application/json", + 'Accept': 'application/json', + 'Content-Type': 'application/json', } if headers: @@ -1062,9 +1055,7 @@ async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, A try: timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA) async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: - async with session.get( - url, headers=_headers, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL - ) as response: + async with session.get(url, headers=_headers, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL) as response: if response.status != 200: error_body = await response.json() raise Exception(error_body) @@ -1072,7 +1063,7 @@ async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, A text_content = None # Check if URL ends with .yaml or .yml to determine format - if url.lower().endswith((".yaml", ".yml")): + if url.lower().endswith(('.yaml', '.yml')): text_content = await response.text() res = yaml.safe_load(text_content) else: @@ -1087,14 +1078,14 @@ async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, A raise e except Exception as err: - log.exception(f"Could not fetch tool server spec from {url}") - if isinstance(err, dict) and "detail" in err: - error = err["detail"] + log.exception(f'Could not fetch tool server spec from {url}') + if isinstance(err, dict) and 'detail' in err: + error = err['detail'] else: error = str(err) raise Exception(error) - log.debug(f"Fetched data: {res}") + log.debug(f'Fetched data: {res}') return res @@ -1104,46 +1095,43 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, tasks = [] server_entries = [] for idx, server in enumerate(servers): - if ( - server.get("config", {}).get("enable") - and server.get("type", "openapi") == "openapi" - ): - info = server.get("info", {}) + if server.get('config', {}).get('enable') and server.get('type', 'openapi') == 'openapi': + info = server.get('info', {}) - auth_type = server.get("auth_type", "bearer") + auth_type = server.get('auth_type', 'bearer') token = None - if auth_type == "bearer": - token = server.get("key", "") - elif auth_type == "none": + if auth_type == 'bearer': + token = server.get('key', '') + elif auth_type == 'none': # No authentication pass - id = info.get("id") + id = info.get('id') if not id: id = str(idx) - server_url = server.get("url") - spec_type = server.get("spec_type", "url") + server_url = server.get('url') + spec_type = server.get('spec_type', 'url') # Create async tasks to fetch data task = None - if spec_type == "url": + if spec_type == 'url': # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL - openapi_path = server.get("path", "openapi.json") + openapi_path = server.get('path', 'openapi.json') spec_url = get_tool_server_url(server_url, openapi_path) # Fetch from URL task = get_tool_server_data( spec_url, - {"Authorization": f"Bearer {token}"} if token else None, + {'Authorization': f'Bearer {token}'} if token else None, ) - elif spec_type == "json" and server.get("spec", ""): + elif spec_type == 'json' and server.get('spec', ''): # Use provided JSON spec spec_json = None try: - spec_json = json.loads(server.get("spec", "")) + spec_json = json.loads(server.get('spec', '')) except Exception as e: - log.error(f"Error parsing JSON spec for tool server {id}: {e}") + log.error(f'Error parsing JSON spec for tool server {id}: {e}') if spec_json: task = asyncio.sleep( @@ -1162,38 +1150,38 @@ async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, results = [] for (id, idx, server, url, info, _), response in zip(server_entries, responses): if isinstance(response, Exception): - log.error(f"Failed to connect to {url} OpenAPI tool server") + log.error(f'Failed to connect to {url} OpenAPI tool server') continue # Guard against invalid or non-OpenAPI specs (e.g., MCP-style configs) - if not isinstance(response, dict) or "paths" not in response: + if not isinstance(response, dict) or 'paths' not in response: log.warning(f"Invalid OpenAPI spec from {url}: missing 'paths'") continue response = { - "openapi": response, - "info": response.get("info", {}), - "specs": convert_openapi_to_tool_payload(response), + 'openapi': response, + 'info': response.get('info', {}), + 'specs': convert_openapi_to_tool_payload(response), } - openapi_data = response.get("openapi", {}) + openapi_data = response.get('openapi', {}) if info and isinstance(openapi_data, dict): - openapi_data["info"] = openapi_data.get("info", {}) + openapi_data['info'] = openapi_data.get('info', {}) - if "name" in info: - openapi_data["info"]["title"] = info.get("name", "Tool Server") + if 'name' in info: + openapi_data['info']['title'] = info.get('name', 'Tool Server') - if "description" in info: - openapi_data["info"]["description"] = info.get("description", "") + if 'description' in info: + openapi_data['info']['description'] = info.get('description', '') results.append( { - "id": str(id), - "idx": idx, - "url": (server.get("url") or "").rstrip("/"), - "openapi": openapi_data, - "info": response.get("info"), - "specs": response.get("specs"), + 'id': str(id), + 'idx': idx, + 'url': (server.get('url') or '').rstrip('/'), + 'openapi': openapi_data, + 'info': response.get('info'), + 'specs': response.get('specs'), } ) @@ -1210,31 +1198,31 @@ async def execute_tool_server( ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: error = None try: - openapi = server_data.get("openapi", {}) - paths = openapi.get("paths", {}) + openapi = server_data.get('openapi', {}) + paths = openapi.get('paths', {}) matching_route = None for route_path, methods in paths.items(): for http_method, operation in methods.items(): - if isinstance(operation, dict) and operation.get("operationId") == name: + if isinstance(operation, dict) and operation.get('operationId') == name: matching_route = (route_path, methods) break if matching_route: break if not matching_route: - raise Exception(f"No matching route found for operationId: {name}") + raise Exception(f'No matching route found for operationId: {name}') route_path, methods = matching_route method_entry = None for http_method, operation in methods.items(): - if operation.get("operationId") == name: + if operation.get('operationId') == name: method_entry = (http_method.lower(), operation) break if not method_entry: - raise Exception(f"No matching method found for operationId: {name}") + raise Exception(f'No matching method found for operationId: {name}') http_method, operation = method_entry @@ -1242,36 +1230,40 @@ async def execute_tool_server( query_params = {} body_params = {} - for param in operation.get("parameters", []): - param_name = param.get("name") + for param in operation.get('parameters', []): + param_name = param.get('name') if not param_name: continue - param_in = param.get("in") + param_in = param.get('in') if param_name in params: - if param_in == "path": + if param_in == 'path': path_params[param_name] = params[param_name] - elif param_in == "query": - if params[param_name] is not None: - query_params[param_name] = params[param_name] + if param_in == 'query': + value = params[param_name] + # Skip empty values for optional params (LLMs sometimes + # pass "" instead of omitting optional parameters). + if value is None or (value == '' and not param.get('required')): + continue + query_params[param_name] = value - final_url = f"{url.rstrip('/')}{route_path}" + final_url = f'{url.rstrip("/")}{route_path}' for key, value in path_params.items(): - final_url = final_url.replace(f"{{{key}}}", str(value)) + final_url = final_url.replace(f'{{{key}}}', str(value)) if query_params: - query_string = "&".join(f"{k}={v}" for k, v in query_params.items()) - final_url = f"{final_url}?{query_string}" + query_string = '&'.join(f'{k}={v}' for k, v in query_params.items()) + final_url = f'{final_url}?{query_string}' - if operation.get("requestBody", {}).get("content"): + if operation.get('requestBody', {}).get('content'): if params: body_params = params async with aiohttp.ClientSession( - trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) + trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER) ) as session: request_method = getattr(session, http_method.lower()) - if http_method in ["post", "put", "patch", "delete"]: + if http_method in ['post', 'put', 'patch', 'delete']: async with request_method( final_url, json=body_params, @@ -1282,12 +1274,18 @@ async def execute_tool_server( ) as response: if response.status >= 400: text = await response.text() - raise Exception(f"HTTP error {response.status}: {text}") + raise Exception(f'HTTP error {response.status}: {text}') try: response_data = await response.json() except Exception: - response_data = await response.text() + content_type = response.headers.get('Content-Type', '').split(';')[0].strip() + if content_type.startswith('text/') or not content_type: + response_data = await response.text() + else: + raw = await response.read() + b64 = base64.b64encode(raw).decode() + response_data = f'data:{content_type};base64,{b64}' response_headers = response.headers return (response_data, response_headers) @@ -1301,32 +1299,38 @@ async def execute_tool_server( ) as response: if response.status >= 400: text = await response.text() - raise Exception(f"HTTP error {response.status}: {text}") + raise Exception(f'HTTP error {response.status}: {text}') try: response_data = await response.json() except Exception: - response_data = await response.text() + content_type = response.headers.get('Content-Type', '').split(';')[0].strip() + if content_type.startswith('text/') or not content_type: + response_data = await response.text() + else: + raw = await response.read() + b64 = base64.b64encode(raw).decode() + response_data = f'data:{content_type};base64,{b64}' response_headers = response.headers return (response_data, response_headers) except Exception as err: error = str(err) - log.exception(f"API Request Error: {error}") - return ({"error": error}, None) + log.exception(f'API Request Error: {error}') + return ({'error': error}, None) def get_tool_server_url(url: Optional[str], path: str) -> str: """ Build the full URL for a tool server, given a base url and a path. """ - if "://" in path: + if '://' in path: # If it contains "://", it's a full URL return path if url: - url = url.rstrip("/") - if not path.startswith("/"): + url = url.rstrip('/') + if not path.startswith('/'): # Ensure the path starts with a slash - path = f"/{path}" - return f"{url}{path}" + path = f'/{path}' + return f'{url}{path}' diff --git a/backend/open_webui/utils/validate.py b/backend/open_webui/utils/validate.py index 6e62dd5416..c2064de257 100644 --- a/backend/open_webui/utils/validate.py +++ b/backend/open_webui/utils/validate.py @@ -2,8 +2,8 @@ # Known static asset paths used as default profile images _ALLOWED_STATIC_PATHS = ( - "/user.png", - "/static/favicon.png", + '/user.png', + '/static/favicon.png', ) @@ -22,10 +22,10 @@ def validate_profile_image_url(url: str) -> str: return url _ALLOWED_DATA_PREFIXES = ( - "data:image/png", - "data:image/jpeg", - "data:image/gif", - "data:image/webp", + 'data:image/png', + 'data:image/jpeg', + 'data:image/gif', + 'data:image/webp', ) if any(url.startswith(prefix) for prefix in _ALLOWED_DATA_PREFIXES): return url @@ -33,6 +33,4 @@ def validate_profile_image_url(url: str) -> str: if url in _ALLOWED_STATIC_PATHS: return url - raise ValueError( - "Invalid profile image URL: only data URIs and default avatars are allowed." - ) + raise ValueError('Invalid profile image URL: only data URIs and default avatars are allowed.') diff --git a/backend/open_webui/utils/webhook.py b/backend/open_webui/utils/webhook.py index b3a3c6bcd1..11c94675d1 100644 --- a/backend/open_webui/utils/webhook.py +++ b/backend/open_webui/utils/webhook.py @@ -8,44 +8,40 @@ log = logging.getLogger(__name__) +# Let this message reach those for whom it was written, and +# may no network partition deny the word its destination. async def post_webhook(name: str, url: str, message: str, event_data: dict) -> bool: try: - log.debug(f"post_webhook: {url}, {message}, {event_data}") + log.debug(f'post_webhook: {url}, {message}, {event_data}') payload = {} # Slack and Google Chat Webhooks - if "https://hooks.slack.com" in url or "https://chat.googleapis.com" in url: - payload["text"] = message + if 'https://hooks.slack.com' in url or 'https://chat.googleapis.com' in url: + payload['text'] = message # Discord Webhooks - elif "https://discord.com/api/webhooks" in url: - payload["content"] = ( - message - if len(message) < 2000 - else f"{message[: 2000 - 20]}... (truncated)" - ) + elif 'https://discord.com/api/webhooks' in url: + payload['content'] = message if len(message) < 2000 else f'{message[: 2000 - 20]}... (truncated)' # Microsoft Teams Webhooks - elif "webhook.office.com" in url: - action = event_data.get("action", "undefined") - user_data = event_data.get("user", "{}") + elif 'webhook.office.com' in url: + action = event_data.get('action', 'undefined') + user_data = event_data.get('user', '{}') if isinstance(user_data, dict): user_dict = user_data else: user_dict = json.loads(user_data) - facts = [ - {"name": name, "value": value} for name, value in user_dict.items() - ] + facts = [{'name': name, 'value': value} for name, value in user_dict.items()] payload = { - "@type": "MessageCard", - "@context": "http://schema.org/extensions", - "themeColor": "0076D7", - "summary": message, - "sections": [ + '@type': 'MessageCard', + '@context': 'http://schema.org/extensions', + 'themeColor': '0076D7', + 'summary': message, + 'sections': [ { - "activityTitle": message, - "activitySubtitle": f"{name} ({VERSION}) - {action}", - "activityImage": WEBUI_FAVICON_URL, - "facts": facts, - "markdown": True, + 'activityTitle': message, + 'activitySubtitle': f'{name} ({VERSION}) - {action}', + 'activityImage': WEBUI_FAVICON_URL, + 'facts': facts, + 'markdown': True, } ], } @@ -53,14 +49,14 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b else: payload = {**event_data} - log.debug(f"payload: {payload}") + log.debug(f'payload: {payload}') async with aiohttp.ClientSession( trust_env=True, timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT) ) as session: async with session.post(url, json=payload) as r: r_text = await r.text() r.raise_for_status() - log.debug(f"r.text: {r_text}") + log.debug(f'r.text: {r_text}') return True except Exception as e: diff --git a/backend/requirements.txt b/backend/requirements.txt index d252420c26..c58592ce26 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,7 +6,7 @@ itsdangerous==2.2.0 python-socketio==5.16.1 python-jose==3.5.0 -cryptography +cryptography==46.0.5 bcrypt==5.0.0 argon2-cffi==25.1.0 PyJWT[crypto]==2.11.0 @@ -14,9 +14,9 @@ authlib==1.6.9 requests==2.32.5 aiohttp==3.13.2 # do not update to 3.13.3 - broken -async-timeout -aiocache -aiofiles +async-timeout==5.0.1 +aiocache==0.12.3 +aiofiles==25.1.0 starlette-compress==1.7.0 Brotli==1.1.0 httpx[socks,http2,zstd,cli,brotli]==0.28.1 @@ -29,7 +29,7 @@ peewee==3.19.0 peewee-migrate==1.14.3 pycrdt==0.12.47 -redis +redis==7.4.0 APScheduler==3.11.2 RestrictedPython==8.1 @@ -39,11 +39,11 @@ loguru==0.7.3 asgiref==3.11.1 # AI libraries -tiktoken +tiktoken==0.12.0 mcp==1.26.0 -openai -anthropic +openai==2.29.0 +anthropic==0.86.0 google-genai==1.66.0 langchain==1.2.10 @@ -58,7 +58,7 @@ opensearch-py==3.1.0 transformers==5.3.0 sentence-transformers==5.2.3 -accelerate +accelerate==1.13.0 pyarrow==20.0.0 # fix: pin pyarrow version to 20 for rpi compatibility #15897 einops==0.8.2 @@ -69,19 +69,20 @@ fpdf2==2.8.7 pymdown-extensions==10.21 docx2txt==0.9 python-pptx==1.0.2 -unstructured==0.18.31 msoffcrypto-tool==6.0.0 +unstructured==0.18.31 + nltk==3.9.3 Markdown==3.10.2 -beautifulsoup4 +beautifulsoup4==4.14.3 pypandoc==1.16.2 pandas==3.0.1 openpyxl==3.1.5 pyxlsb==1.0.10 xlrd==2.0.2 validators==0.35.0 -psutil -sentencepiece +psutil==7.2.2 +sentencepiece==0.2.1 jsonpath-ng soundfile==0.13.1 @@ -97,8 +98,8 @@ black==26.1.0 youtube-transcript-api==1.2.4 pytube==15.0.0 -pydub -ddgs==9.11.2 +pydub==0.25.1 +ddgs==9.11.3 azure-ai-documentintelligence==1.0.2 azure-identity==1.25.2 @@ -106,21 +107,21 @@ azure-storage-blob==12.28.0 azure-search-documents==11.6.0 ## Google Drive -google-api-python-client -google-auth-httplib2 -google-auth-oauthlib +google-api-python-client==2.193.0 +google-auth-httplib2==0.3.0 +google-auth-oauthlib==1.3.0 googleapis-common-protos==1.72.0 google-cloud-storage==3.9.0 ## Databases -pymongo +pymongo==4.16.0 psycopg2-binary==2.9.11 pgvector==0.4.2 PyMySQL==1.1.2 boto3==1.42.62 -mariadb==1.1.14 +# mariadb==1.1.14 should be added if you want to support MariaDB pymilvus==2.6.9 qdrant-client==1.17.0 diff --git a/backend/start.sh b/backend/start.sh index 5729babe52..8ee41bacbe 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -50,7 +50,7 @@ if [ -n "$SPACE_ID" ]; then echo "Configuring for HuggingFace Space deployment" if [ -n "$ADMIN_USER_EMAIL" ] && [ -n "$ADMIN_USER_PASSWORD" ]; then echo "Admin user configured, creating" - WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips '*' & + WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" uvicorn open_webui.main:app --host "$HOST" --port "$PORT" --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" & webui_pid=$! echo "Waiting for webui to start..." while ! curl -s "http://localhost:${PORT}/health" > /dev/null; do @@ -88,5 +88,5 @@ echo "Starting Open WebUI on http://$HOST:$PORT with $UVICORN_WORKERS workers... WEBUI_SECRET_KEY="$WEBUI_SECRET_KEY" exec "$PYTHON_CMD" -m uvicorn open_webui.main:app \ --host "$HOST" \ --port "$PORT" \ - --forwarded-allow-ips '*' \ + --forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \ "${ARGS[@]}" \ No newline at end of file diff --git a/backend/start_windows.bat b/backend/start_windows.bat index f350d11cd1..c8587c3c6d 100644 --- a/backend/start_windows.bat +++ b/backend/start_windows.bat @@ -24,6 +24,7 @@ IF NOT "%WEBUI_SECRET_KEY_FILE%" == "" ( IF "%PORT%"=="" SET PORT=8080 IF "%HOST%"=="" SET HOST=0.0.0.0 +IF "%FORWARDED_ALLOW_IPS%"=="" SET "FORWARDED_ALLOW_IPS=*" SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%" @@ -46,5 +47,5 @@ IF "%WEBUI_SECRET_KEY% %WEBUI_JWT_SECRET_KEY%" == " " ( :: Execute uvicorn SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" IF "%UVICORN_WORKERS%"=="" SET UVICORN_WORKERS=1 -uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --workers %UVICORN_WORKERS% --ws auto +uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips "%FORWARDED_ALLOW_IPS%" --workers %UVICORN_WORKERS% --ws auto :: For ssl user uvicorn open_webui.main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' --ssl-keyfile "key.pem" --ssl-certfile "cert.pem" --ws auto diff --git a/contribution_stats.py b/contribution_stats.py index 3caa4738ec..2dd9eab187 100644 --- a/contribution_stats.py +++ b/contribution_stats.py @@ -2,15 +2,15 @@ import subprocess from collections import Counter -CONFIG_FILE_EXTENSIONS = (".json", ".yml", ".yaml", ".ini", ".conf", ".toml") +CONFIG_FILE_EXTENSIONS = ('.json', '.yml', '.yaml', '.ini', '.conf', '.toml') def is_text_file(filepath): # Check for binary file by scanning for null bytes. try: - with open(filepath, "rb") as f: + with open(filepath, 'rb') as f: chunk = f.read(4096) - if b"\0" in chunk: + if b'\0' in chunk: return False return True except Exception: @@ -20,7 +20,7 @@ def is_text_file(filepath): def should_skip_file(path): base = os.path.basename(path) # Skip dotfiles and dotdirs - if base.startswith("."): + if base.startswith('.'): return True # Skip config files by extension if base.lower().endswith(CONFIG_FILE_EXTENSIONS): @@ -30,12 +30,12 @@ def should_skip_file(path): def get_tracked_files(): try: - output = subprocess.check_output(["git", "ls-files"], text=True) - files = output.strip().split("\n") + output = subprocess.check_output(['git', 'ls-files'], text=True) + files = output.strip().split('\n') files = [f for f in files if f and os.path.isfile(f)] return files except subprocess.CalledProcessError: - print("Error: Are you in a git repository?") + print('Error: Are you in a git repository?') return [] @@ -50,14 +50,12 @@ def main(): if not is_text_file(file): continue try: - blame = subprocess.check_output( - ["git", "blame", "-e", file], text=True, errors="replace" - ) + blame = subprocess.check_output(['git', 'blame', '-e', file], text=True, errors='replace') for line in blame.splitlines(): # The email always inside <> - if "<" in line and ">" in line: + if '<' in line and '>' in line: try: - email = line.split("<")[1].split(">")[0].strip() + email = line.split('<')[1].split('>')[0].strip() except Exception: continue email_counter[email] += 1 @@ -67,8 +65,8 @@ def main(): for email, lines in email_counter.most_common(): percent = (lines / total_lines * 100) if total_lines else 0 - print(f"{email}: {lines}/{total_lines} {percent:.2f}%") + print(f'{email}: {lines}/{total_lines} {percent:.2f}%') -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 07a70ee63b..1e310a9b79 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -76,13 +76,18 @@ Your remediation guidance can include, for example: > > **Using CVE Precedents:** If you cite other CVEs to support your report, ensure they are **genuinely comparable** in vulnerability type, threat model, and attack vector. Citing CVEs from different product categories, different vulnerability classes or different deployment models will lead us to suspect the use of AI in your report. -9. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. **Admins have full system control and are expected to understand the security implications of their actions and configurations**. This includes but is not limited to: adding malicious external servers (models, tools, webhooks), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.** +9. **Admin Actions Are Out of Scope:** Vulnerabilities that require an administrator to actively perform unsafe actions are **not considered valid vulnerabilities**. **Admins have full system control and are expected to understand the security implications of their actions and configurations**. This includes but is not limited to: adding malicious external servers (models, tools, webhooks, functions), pasting untrusted code into Functions/Tools, or intentionally weakening security settings. **Reports requiring admin negligence or social engineering of admins may be rejected.** > [!NOTE] > 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. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **you MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it. +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 rule 7, [Threat Model Understanding](#threat-model-understanding-required)). 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 rule 9 ([Admin Actions Are Out of Scope](#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`). + +> [!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/). + +11. **AI report transparency:** Due to an extreme spike in AI-aided vulnerability reports **you MUST DISCLOSE if AI was used in any capacity** - whether for writing the report, generating the PoC, or identifying the vulnerability. If AI helped you in any way shape or form in the creation of the report, PoC or finding the vulnerability, you MUST disclose it. > [!NOTE] > AI-aided vulnerability reports **will not be rejected by us by default**. But: @@ -107,6 +112,24 @@ Your remediation guidance can include, for example: If you want to report a vulnerability and can meet the outlined requirements, [open a vulnerability report here](https://github.com/open-webui/open-webui/security/advisories/new). If you feel like you are not able to follow ALL outlined requirements for vulnerability-specific reasons, still do report it, we will check every report either way. +## Expected Response Timeframe + +Due to the volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance โ€” lately compounded by a large number of invalid AI-generated reports (see [AI report transparency](#ai-report-transparency)) โ€” 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. + +**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. The entire process can realistically take multiple weeks from initial submission to final publication. We appreciate your patience and understanding. + +## Confidential Disclosure + +Vulnerability reports submitted through GitHub Security Advisories are **private and confidential**. Public disclosure of **ANY** details related to a submitted vulnerability report is **STRICTLY PROHIBITED** until the advisory has been **fully published** โ€” not merely when a CVE ID has been assigned, but when the advisory itself is publicly visible. + +This prohibition applies to **all channels**, including but not limited to: + +- Comments on pull requests, issues, or discussions (on GitHub or elsewhere) +- Social media, blogs, forums, or any other website +- Discord, Reddit, or any other platform, website or service + +Premature disclosure undermines the security of all Open WebUI users and **violates the trust** inherent in the responsible disclosure process. **Reporters who publicly disclose vulnerability details before official publication may be permanently banned from future reporting.** + ## Product Security And For Non-Vulnerability Related Security Concerns: If your concern does not meet the vulnerability requirements outlined above, is not a vulnerability, **but is still related to security concerns**, then use the following channels instead: @@ -126,7 +149,7 @@ If your concern does not meet the vulnerability requirements outlined above, is - Feature requests for optional security enhancements (2FA, audit logging, etc.) - General security questions about production deployment -Please use the adequate channel for your specific issue - e.g. best-practice guidance or **dditional documentation needs into the [Documentation Repository](https://github.com/open-webui/docs)**, and **feature requests into the Main Repository as an issue or discussion**. +Please use the adequate channel for your specific issue - e.g. best-practice guidance or additional documentation needs into the [Documentation Repository](https://github.com/open-webui/docs), and feature requests into the Main Repository as an issue or discussion. We regularly audit our internal processes and system architecture for vulnerabilities using a combination of automated and manual testing techniques. We are also planning to implement SAST and SCA scans in our project soon. @@ -134,4 +157,4 @@ For any other immediate concerns and questions, please create an issue in our [i --- -_Last updated on **2026-02-25**._ +_Last updated on **2026-03-20**._ diff --git a/hatch_build.py b/hatch_build.py index 28aad1b6cd..f18f323635 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -10,14 +10,12 @@ class CustomBuildHook(BuildHookInterface): def initialize(self, version, build_data): super().initialize(version, build_data) - stderr.write(">>> Building Open Webui frontend\n") - npm = shutil.which("npm") + stderr.write('>>> Building Open Webui frontend\n') + npm = shutil.which('npm') if npm is None: - raise RuntimeError( - "NodeJS `npm` is required for building Open Webui but it was not found" - ) - stderr.write("### npm install\n") - subprocess.run([npm, "install", "--force"], check=True) # noqa: S603 - stderr.write("\n### npm run build\n") - os.environ["APP_BUILD_HASH"] = version - subprocess.run([npm, "run", "build"], check=True) # noqa: S603 + raise RuntimeError('NodeJS `npm` is required for building Open Webui but it was not found') + stderr.write('### npm install\n') + subprocess.run([npm, 'install', '--force'], check=True) # noqa: S603 + stderr.write('\n### npm run build\n') + os.environ['APP_BUILD_HASH'] = version + subprocess.run([npm, 'run', 'build'], check=True) # noqa: S603 diff --git a/package-lock.json b/package-lock.json index 6e87cfee06..e876da5288 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.10.2", + "version": "0.8.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.10.2", + "version": "0.8.11.1", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", @@ -21,12 +21,12 @@ "@sveltejs/adapter-node": "^2.0.0", "@sveltejs/svelte-virtual-list": "^3.0.1", "@tiptap/core": "^3.0.7", - "@tiptap/extension-bubble-menu": "^2.26.1", + "@tiptap/extension-bubble-menu": "^3.0.7", "@tiptap/extension-code": "^3.0.7", "@tiptap/extension-code-block-lowlight": "^3.0.7", "@tiptap/extension-drag-handle": "^3.4.5", "@tiptap/extension-file-handler": "^3.0.7", - "@tiptap/extension-floating-menu": "^2.26.1", + "@tiptap/extension-floating-menu": "^3.0.7", "@tiptap/extension-highlight": "^3.3.0", "@tiptap/extension-image": "^3.0.7", "@tiptap/extension-link": "^3.0.7", @@ -45,7 +45,7 @@ "@xyflow/svelte": "^0.1.19", "alpinejs": "^3.15.0", "async": "^3.2.5", - "bits-ui": "^0.21.15", + "bits-ui": "^2.0.0", "chart.js": "^4.5.0", "codemirror": "^6.0.1", "codemirror-lang-elixir": "^4.0.0", @@ -132,9 +132,9 @@ "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", "sass-embedded": "^1.81.0", - "svelte": "^5.0.0", + "svelte": "^5.53.10", "svelte-check": "^4.0.0", - "svelte-confetti": "^1.3.2", + "svelte-confetti": "^2.3.2", "tailwindcss": "^4.0.0", "tslib": "^2.4.1", "typescript": "^5.5.4", @@ -146,20 +146,12 @@ "npm": ">=6.0.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -168,141 +160,129 @@ } }, "node_modules/@antfu/install-pkg": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.0.0.tgz", - "integrity": "sha512-xvX6P/lo1B3ej0OsaErAjqgFYzYVcJpamjLAFLYh9vRJngBrMoUG7aVnrGTeqM7yxbyTD5p3F2+0/QUEh8Vzhw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", "license": "MIT", "dependencies": { - "package-manager-detector": "^0.2.8", - "tinyexec": "^0.3.2" + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@antfu/utils": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-8.1.1.tgz", - "integrity": "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/@azure/msal-browser": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.5.0.tgz", - "integrity": "sha512-H7mWmu8yI0n0XxhJobrgncXI6IU5h8DKMiWDHL5y+Dc58cdg26GbmaMUehbUkdKAQV2OTiFa4FUa6Fdu/wIxBg==", + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.29.1.tgz", + "integrity": "sha512-1Vrt27du1cl4QHkzLc6L4aeXqliPIDIs5l/1I4hWWMXkXccY/EznJT1+pBdoVze0azTAI8sCyq5B4cBVYG1t9w==", "license": "MIT", "dependencies": { - "@azure/msal-common": "15.2.0" + "@azure/msal-common": "15.16.1" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.2.0.tgz", - "integrity": "sha512-HiYfGAKthisUYqHG1nImCf/uzcyS31wng3o+CycWLIM9chnYJ9Lk6jZ30Y6YiYYpTQ9+z/FGUpiKKekd3Arc0A==", + "version": "15.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.16.1.tgz", + "integrity": "sha512-qxUG9TCl+TVSSX58onVDHDWrvT5CE0+NeeUAbkQqaESpSm79u5IePLnPWMMjCUnUR2zJd4+Bt9vioVRzLmJb2g==", "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@braintree/sanitize-url": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", - "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, "node_modules/@bufbuild/protobuf": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.2.tgz", - "integrity": "sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", + "integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==", "devOptional": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", - "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.0.3", - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/gast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", - "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", - "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", - "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", - "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "license": "Apache-2.0" }, "node_modules/@codemirror/autocomplete": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.2.tgz", - "integrity": "sha512-MjfDrHy0gHKlPWsvSsikhO1+BOh+eBHNgfH1OXs1+DAf30IonQldgMM3kxLDTG9ktE7kDLaA1j/l7KMPA4KNfw==", + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" } }, "node_modules/@codemirror/commands": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", - "integrity": "sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==", + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.4.0", + "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "node_modules/@codemirror/lang-angular": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.3.tgz", - "integrity": "sha512-xgeWGJQQl1LyStvndWtruUvb4SnBZDAu/gvFH/ZU+c0W25tQR8e5hq7WTwiIY2dNxnf+49mRiGI/9yxIwB6f5w==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", + "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", + "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", @@ -313,18 +293,20 @@ } }, "node_modules/@codemirror/lang-cpp": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.2.tgz", - "integrity": "sha512-6oYEYUKHvrnacXxWxYa6t4puTlbN3dgV662BDfSH8+MfjQjVmP697/KYTDOqpxgerkvoNm7q5wlFMBeX8ZMocg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "node_modules/@codemirror/lang-css": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz", - "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -337,6 +319,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -346,9 +329,10 @@ } }, "node_modules/@codemirror/lang-html": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", - "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -358,22 +342,24 @@ "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", - "@lezer/html": "^1.3.0" + "@lezer/html": "^1.3.12" } }, "node_modules/@codemirror/lang-java": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.1.tgz", - "integrity": "sha512-OOnmhH67h97jHzCuFaIEspbmsT98fNdhVhmA3zCxW0cn7l8rChDhZtwiwJ/JOKXgfm4J+ELxQihxaI7bj7mJRg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "node_modules/@codemirror/lang-javascript": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", - "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -384,10 +370,24 @@ "@lezer/javascript": "^1.0.0" } }, + "node_modules/@codemirror/lang-jinja": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.0.tgz", + "integrity": "sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@codemirror/lang-json": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.1.tgz", - "integrity": "sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" @@ -397,6 +397,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", @@ -406,9 +407,10 @@ } }, "node_modules/@codemirror/lang-liquid": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.2.1.tgz", - "integrity": "sha512-J1Mratcm6JLNEiX+U2OlCDTysGuwbHD76XwuL5o5bo9soJtSbz2g6RU3vGHFyS5DC8rgVmFSzi7i6oBftm7tnA==", + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz", + "integrity": "sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-html": "^6.0.0", @@ -421,9 +423,10 @@ } }, "node_modules/@codemirror/lang-markdown": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.0.tgz", - "integrity": "sha512-lYrI8SdL/vhd0w0aHIEvIRLRecLF7MiiRfzXFZY94dFwHqC9HtgxgagJ8fyYNBldijGatf9wkms60d8SrAj6Nw==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", @@ -435,9 +438,10 @@ } }, "node_modules/@codemirror/lang-php": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz", - "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -447,9 +451,10 @@ } }, "node_modules/@codemirror/lang-python": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.1.6.tgz", - "integrity": "sha512-ai+01WfZhWqM92UqjnvorkxosZ2aq2u28kHvr+N3gu012XqY2CThD67JPMHnGceRfXPDBmn1HnyqowdpF57bNg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", @@ -459,9 +464,10 @@ } }, "node_modules/@codemirror/lang-rust": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.1.tgz", - "integrity": "sha512-344EMWFBzWArHWdZn/NcgkwMvZIWUR1GEBdwG8FEp++6o6vT6KL9V7vGs2ONsKxxFUPXKI0SPcWhyYyl2zPYxQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" @@ -471,6 +477,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", "dependencies": { "@codemirror/lang-css": "^6.2.0", "@codemirror/language": "^6.0.0", @@ -480,9 +487,10 @@ } }, "node_modules/@codemirror/lang-sql": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.8.0.tgz", - "integrity": "sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -496,6 +504,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "license": "MIT", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", @@ -509,6 +518,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", @@ -520,6 +530,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", @@ -530,35 +541,39 @@ } }, "node_modules/@codemirror/lang-yaml": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.1.tgz", - "integrity": "sha512-HV2NzbK9bbVnjWxwObuZh5FuPCowx51mEfoFT9y3y+M37fA3+pbxx4I7uePuygFzDsAmCTwQSc/kXh/flab4uw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", + "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "node_modules/@codemirror/language": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz", - "integrity": "sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==", + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz", + "integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "node_modules/@codemirror/language-data": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.1.tgz", - "integrity": "sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", + "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==", + "license": "MIT", "dependencies": { "@codemirror/lang-angular": "^0.1.0", "@codemirror/lang-cpp": "^6.0.0", @@ -567,6 +582,7 @@ "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-java": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.0", "@codemirror/lang-less": "^6.0.0", "@codemirror/lang-liquid": "^6.0.0", @@ -585,42 +601,50 @@ } }, "node_modules/@codemirror/legacy-modes": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.1.tgz", - "integrity": "sha512-vdg3XY7OAs5uLDx2Iw+cGfnwtd7kM+Et/eMsqAGTfT/JKiVBQZXosTzjEbWAi/FrY6DcQIz8mQjBozFHZEUWQA==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0" } }, "node_modules/@codemirror/lint": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.0.tgz", - "integrity": "sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==", + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/search": { - "version": "6.5.6", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", - "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/state": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", - "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } }, "node_modules/@codemirror/theme-one-dark": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", - "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -629,11 +653,13 @@ } }, "node_modules/@codemirror/view": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.0.tgz", - "integrity": "sha512-fo7CelaUDKWIyemw4b+J57cWuRkOu4SWCCPfNDkPvfWkGjM9D5racHQXr4EQeYCD6zEBIBxGCeaKkQo+ysl0gA==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", + "license": "MIT", "dependencies": { - "@codemirror/state": "^6.4.0", + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } @@ -643,16 +669,18 @@ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", "dev": true, + "license": "MIT", "optional": true, "engines": { "node": ">=0.1.90" } }, "node_modules/@cypress/request": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.5.tgz", - "integrity": "sha512-v+XHd9XmWbufxF1/bTaVm2yhbxY+TB4YtWRqF2zaXBlDNMkls34KiATz0AVDLavL3iB6bQk9/7n3oY1EoLSWGA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -660,16 +688,16 @@ "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", - "form-data": "~4.0.0", + "form-data": "~4.0.4", "http-signature": "~1.4.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "6.13.0", + "qs": "~6.14.1", "safe-buffer": "^5.1.2", - "tough-cookie": "^4.1.3", + "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", "uuid": "^8.3.2" }, @@ -682,6 +710,7 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true, + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } @@ -691,6 +720,7 @@ "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^3.1.0", "lodash.once": "^4.1.1" @@ -701,23 +731,25 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", - "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -732,9 +764,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", - "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -749,9 +781,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", - "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -766,9 +798,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", - "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -783,9 +815,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", - "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -800,9 +832,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", - "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -817,9 +849,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", - "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -834,9 +866,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", - "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -851,9 +883,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", - "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -868,9 +900,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", - "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -885,9 +917,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", - "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -902,9 +934,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", - "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -919,9 +951,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", - "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -936,9 +968,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", - "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -953,9 +985,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", - "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -970,9 +1002,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", - "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -987,9 +1019,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", - "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -1004,9 +1036,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", - "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -1021,9 +1053,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", - "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -1038,9 +1070,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", - "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -1055,9 +1087,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", - "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -1071,10 +1103,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", - "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -1089,9 +1138,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", - "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -1106,9 +1155,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", - "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -1123,9 +1172,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", - "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -1140,25 +1189,30 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -1168,6 +1222,7 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1186,6 +1241,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1197,11 +1259,22 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1210,37 +1283,38 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@floating-ui/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", - "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", - "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.2", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@gulpjs/to-absolute-glob": { @@ -1248,6 +1322,7 @@ "resolved": "https://registry.npmjs.org/@gulpjs/to-absolute-glob/-/to-absolute-glob-4.0.0.tgz", "integrity": "sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA==", "dev": true, + "license": "MIT", "dependencies": { "is-negated-glob": "^1.0.0" }, @@ -1256,33 +1331,35 @@ } }, "node_modules/@huggingface/jinja": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.3.3.tgz", - "integrity": "sha512-vQQr2JyWvVFba3Lj9es4q9vCl1sAc74fdgnEMoX8qHrXtswap9ge9uO3ONDzQB0cQ0PUyaKY2N6HaVbTBvSXvw==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.6.tgz", + "integrity": "sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==", "license": "MIT", "engines": { "node": ">=18" } }, "node_modules/@huggingface/transformers": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.3.3.tgz", - "integrity": "sha512-OcMubhBjW6u1xnp0zSt5SvCxdGHuhP2k+w2Vlm3i0vNcTJhJTZWxxYQmPBfcb7PX+Q6c43lGSzWD6tsJFwka4Q==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", "license": "Apache-2.0", "dependencies": { - "@huggingface/jinja": "^0.3.3", - "onnxruntime-node": "1.20.1", - "onnxruntime-web": "1.21.0-dev.20250206-d981b153d3", - "sharp": "^0.33.5" + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -1290,6 +1367,13 @@ "node": ">=10.10.0" } }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1302,10 +1386,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1318,6 +1403,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -1327,10 +1413,12 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@iconify/types": { "version": "2.0.0", @@ -1339,103 +1427,33 @@ "license": "MIT" }, "node_modules/@iconify/utils": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-2.3.0.tgz", - "integrity": "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", "license": "MIT", "dependencies": { - "@antfu/install-pkg": "^1.0.0", - "@antfu/utils": "^8.1.0", + "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", - "debug": "^4.4.0", - "globals": "^15.14.0", - "kolorist": "^1.8.0", - "local-pkg": "^1.0.0", - "mlly": "^1.7.4" - } - }, - "node_modules/@iconify/utils/node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", - "license": "MIT" - }, - "node_modules/@iconify/utils/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "mlly": "^1.8.0" } }, - "node_modules/@iconify/utils/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@iconify/utils/node_modules/local-pkg": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz", - "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", - "license": "MIT", - "dependencies": { - "mlly": "^1.7.4", - "pkg-types": "^2.0.1", - "quansync": "^0.2.8" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@iconify/utils/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/@iconify/utils/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "license": "MIT" - }, - "node_modules/@iconify/utils/node_modules/pkg-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz", - "integrity": "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==", - "license": "MIT", - "dependencies": { - "confbox": "^0.2.1", - "exsolve": "^1.0.1", - "pathe": "^2.0.3" } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -1447,16 +1465,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "darwin" @@ -1468,16 +1487,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -1487,12 +1507,13 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "darwin" @@ -1502,12 +1523,13 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1517,12 +1539,45 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1532,12 +1587,13 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1547,12 +1603,13 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1562,12 +1619,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1577,12 +1635,13 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" @@ -1592,12 +1651,13 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1609,16 +1669,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1630,16 +1691,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ - "s390x" + "ppc64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1651,16 +1713,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ - "x64" + "riscv64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1672,16 +1735,61 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1693,16 +1801,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -1714,20 +1823,40 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1736,12 +1865,13 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -1754,12 +1884,13 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" @@ -1772,10 +1903,11 @@ } }, "node_modules/@internationalized/date": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz", - "integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -1784,6 +1916,8 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -1797,9 +1931,11 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -1807,12 +1943,52 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -1821,6 +1997,24 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1847,9 +2041,9 @@ } }, "node_modules/@joplin/turndown-plugin-gfm": { - "version": "1.0.62", - "resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.62.tgz", - "integrity": "sha512-Ts7cZ0Y9rIRgNkPtpXYB3BVjjSP2eeWzrPnQvJgNTC+FpopSjoaYjLQvPcEj1d6JcTMegnYoZK98/WJhm02Uaw==", + "version": "1.0.64", + "resolved": "https://registry.npmjs.org/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.64.tgz", + "integrity": "sha512-8GJ7f9OenE3zkSVII5B6qzIkvgF7C/a20gaASEjM6jWPLPJFFQ2nQ3Ou/kXH1mPUTs9dC9VYs8QXVPvZabKXBQ==", "license": "MIT" }, "node_modules/@jridgewell/gen-mapping": { @@ -1876,6 +2070,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -1887,9 +2082,10 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -1902,14 +2098,16 @@ "license": "MIT" }, "node_modules/@lezer/common": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", - "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" }, "node_modules/@lezer/cpp": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.2.tgz", - "integrity": "sha512-macwKtyeUO0EW86r3xWQCzOV9/CF8imJLpJlPv3sDY57cPGeUZ8gXWOWNlJr52TVByMV3PayFQCA5SHEERDmVQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz", + "integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -1917,37 +2115,41 @@ } }, "node_modules/@lezer/css": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.9.tgz", - "integrity": "sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz", + "integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@lezer/lr": "^1.3.0" } }, "node_modules/@lezer/go": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.0.tgz", - "integrity": "sha512-co9JfT3QqX1YkrMmourYw2Z8meGC50Ko4d54QEcQbEYpvdUvN4yb0NBZdn/9ertgvjsySxHsKzH3lbm3vqJ4Jw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" + "@lezer/lr": "^1.3.0" } }, "node_modules/@lezer/highlight": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", - "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0" + "@lezer/common": "^1.3.0" } }, "node_modules/@lezer/html": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", - "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -1955,9 +2157,10 @@ } }, "node_modules/@lezer/java": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.2.tgz", - "integrity": "sha512-3j8X70JvYf0BZt8iSRLXLkt0Ry1hVUgH6wT32yBxH/Xi55nW2VMhc1Az4SKwu4YGSmxCm1fsqDDcHTuFjC8pmg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -1965,9 +2168,10 @@ } }, "node_modules/@lezer/javascript": { - "version": "1.4.16", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.16.tgz", - "integrity": "sha512-84UXR3N7s11MPQHWgMnjb9571fr19MmXnr5zTv2XX0gHXXUvW3uPJ8GCjKrfTXmSdfktjRK0ayKklw+A13rk4g==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -1975,9 +2179,10 @@ } }, "node_modules/@lezer/json": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.2.tgz", - "integrity": "sha512-xHT2P4S5eeCYECyKNPhr4cbEL9tc8w83SPwRC373o9uEdrvGKTZoJVAGxpOsZckMlEh9W23Pc72ew918RWQOBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -1985,26 +2190,29 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.1.tgz", - "integrity": "sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==", + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" } }, "node_modules/@lezer/markdown": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.3.1.tgz", - "integrity": "sha512-DGlzU/i8DC8k0uz1F+jeePrkATl0jWakauTzftMQOcbaMkHbNSRki/4E2tOzJWsVpoKYhe7iTJ03aepdwVUXUA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "node_modules/@lezer/php": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz", - "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -2012,9 +2220,10 @@ } }, "node_modules/@lezer/python": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.14.tgz", - "integrity": "sha512-ykDOb2Ti24n76PJsSa4ZoDF0zH12BSw1LGfQXCYJhJyOGiFTfGaX0Du66Ze72R+u/P35U+O6I9m8TFXov1JzsA==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -2025,6 +2234,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -2032,9 +2242,10 @@ } }, "node_modules/@lezer/sass": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.0.7.tgz", - "integrity": "sha512-8HLlOkuX/SMHOggI2DAsXUw38TuURe+3eQ5hiuk9QmYOUyC55B1dYEIMkav5A4IELVaW4e1T4P9WRiI5ka4mdw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -2042,9 +2253,10 @@ } }, "node_modules/@lezer/xml": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.5.tgz", - "integrity": "sha512-VFouqOzmUWfIg+tfmpcdV33ewtK+NSwd4ngSe1aG7HFb4BN0ExyY1b8msp+ndFrnlG4V4iC8yXacjFtrwERnaw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", @@ -2052,38 +2264,47 @@ } }, "node_modules/@lezer/yaml": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz", - "integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@mediapipe/tasks-vision": { - "version": "0.10.17", - "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", - "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==" + "version": "0.10.32", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.32.tgz", + "integrity": "sha512-3tiAZnmKloYnRXYoO3dKltTUGnqeCwzC4lV03uY0vCsE+aveJTyEVQyZHOlQGQNsjK+gRHzkf9q08C99Qm2K0Q==", + "license": "Apache-2.0" }, "node_modules/@mermaid-js/parser": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.2.tgz", - "integrity": "sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz", + "integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==", "license": "MIT", "dependencies": { - "langium": "3.3.1" + "langium": "^4.0.0" } }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", - "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==" + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" }, "node_modules/@napi-rs/canvas": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.78.tgz", - "integrity": "sha512-YaBHJvT+T1DoP16puvWM6w46Lq3VhwKIJ8th5m1iEJyGh7mibk5dT7flBvMQ1EH1LYmMzXJ+OUhu+8wQ9I6u7g==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", "license": "MIT", "optional": true, "workspaces": [ @@ -2092,23 +2313,28 @@ "engines": { "node": ">= 10" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.78", - "@napi-rs/canvas-darwin-arm64": "0.1.78", - "@napi-rs/canvas-darwin-x64": "0.1.78", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.78", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.78", - "@napi-rs/canvas-linux-arm64-musl": "0.1.78", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.78", - "@napi-rs/canvas-linux-x64-gnu": "0.1.78", - "@napi-rs/canvas-linux-x64-musl": "0.1.78", - "@napi-rs/canvas-win32-x64-msvc": "0.1.78" + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.78.tgz", - "integrity": "sha512-N1ikxztjrRmh8xxlG5kYm1RuNr8ZW1EINEDQsLhhuy7t0pWI/e7SH91uFVLZKCMDyjel1tyWV93b5fdCAi7ggw==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", "cpu": [ "arm64" ], @@ -2119,12 +2345,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.78.tgz", - "integrity": "sha512-FA3aCU3G5yGc74BSmnLJTObnZRV+HW+JBTrsU+0WVVaNyVKlb5nMvYAQuieQlRVemsAA2ek2c6nYtHh6u6bwFw==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", "cpu": [ "arm64" ], @@ -2135,12 +2365,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.78.tgz", - "integrity": "sha512-xVij69o9t/frixCDEoyWoVDKgE3ksLGdmE2nvBWVGmoLu94MWUlv2y4Qzf5oozBmydG5Dcm4pRHFBM7YWa1i6g==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", "cpu": [ "x64" ], @@ -2151,12 +2385,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.78.tgz", - "integrity": "sha512-aSEXrLcIpBtXpOSnLhTg4jPsjJEnK7Je9KqUdAWjc7T8O4iYlxWxrXFIF8rV8J79h5jNdScgZpAUWYnEcutR3g==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", "cpu": [ "arm" ], @@ -2167,12 +2405,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.78.tgz", - "integrity": "sha512-dlEPRX1hLGKaY3UtGa1dtkA1uGgFITn2mDnfI6YsLlYyLJQNqHx87D1YTACI4zFCUuLr/EzQDzuX+vnp9YveVg==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", "cpu": [ "arm64" ], @@ -2183,12 +2425,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.78.tgz", - "integrity": "sha512-TsCfjOPZtm5Q/NO1EZHR5pwDPSPjPEttvnv44GL32Zn1uvudssjTLbvaG1jHq81Qxm16GTXEiYLmx4jOLZQYlg==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", "cpu": [ "arm64" ], @@ -2199,12 +2445,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.78.tgz", - "integrity": "sha512-+cpTTb0GDshEow/5Fy8TpNyzaPsYb3clQIjgWRmzRcuteLU+CHEU/vpYvAcSo7JxHYPJd8fjSr+qqh+nI5AtmA==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", "cpu": [ "riscv64" ], @@ -2215,12 +2465,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.78.tgz", - "integrity": "sha512-wxRcvKfvYBgtrO0Uy8OmwvjlnTcHpY45LLwkwVNIWHPqHAsyoTyG/JBSfJ0p5tWRzMOPDCDqdhpIO4LOgXjeyg==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", "cpu": [ "x64" ], @@ -2231,12 +2485,16 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.78.tgz", - "integrity": "sha512-vQFOGwC9QDP0kXlhb2LU1QRw/humXgcbVp8mXlyBqzc/a0eijlLF9wzyarHC1EywpymtS63TAj8PHZnhTYN6hg==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", "cpu": [ "x64" ], @@ -2247,12 +2505,36 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.78", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.78.tgz", - "integrity": "sha512-/eKlTZBtGUgpRKalzOzRr6h7KVSuziESWXgBcBnXggZmimwIJWPJlEcbrx5Tcwj8rPuZiANXQOG9pPgy9Q4LTQ==", + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", "cpu": [ "x64" ], @@ -2263,12 +2545,17 @@ ], "engines": { "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2281,6 +2568,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", "engines": { "node": ">= 8" } @@ -2289,6 +2577,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -2297,41 +2586,340 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, "engines": { - "node": ">=14" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.28", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", - "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", - "license": "MIT" - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "node": ">= 10.0.0" + }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.4", @@ -2386,13 +2974,14 @@ "license": "BSD-3-Clause" }, "node_modules/@pyscript/core": { - "version": "0.4.32", - "resolved": "https://registry.npmjs.org/@pyscript/core/-/core-0.4.32.tgz", - "integrity": "sha512-WQATzPp1ggf871+PukCmTypzScXkEB1EWD/vg5GNxpM96N6rDPqQ13msuA5XvwU01ZVhL8HHSFDLk4IfaXNGWg==", + "version": "0.4.56", + "resolved": "https://registry.npmjs.org/@pyscript/core/-/core-0.4.56.tgz", + "integrity": "sha512-pdjzc16C8zAGzFRP8qVy2lmrEdRH9khCOedPRlDr/5PG5tYEquPggbO1hLb/eUpJH6r3jP/uhW59vuG7yuKwqw==", + "license": "APACHE-2.0", "dependencies": { "@ungap/with-resolvers": "^0.1.0", "basic-devtools": "^0.1.6", - "polyscript": "^0.12.8", + "polyscript": "^0.13.10", "sticky-module": "^0.1.1", "to-json-callback": "^0.1.1", "type-checked-collections": "^0.1.7" @@ -2405,9 +2994,10 @@ "license": "MIT" }, "node_modules/@rollup/plugin-commonjs": { - "version": "25.0.7", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", - "integrity": "sha512-nEvcR+LRjEjsaSsc4x3XZfCCvZIaSMenZu/OiwOKGN2UhQpAYI7ru7czFvyWbErlpoGjnSX3D5Ch5FcMA3kRWQ==", + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", + "integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==", + "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", @@ -2432,6 +3022,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.1.0" }, @@ -2448,14 +3039,14 @@ } }, "node_modules/@rollup/plugin-node-resolve": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", - "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", - "is-builtin-module": "^3.2.1", "is-module": "^1.0.0", "resolve": "^1.22.1" }, @@ -2472,13 +3063,14 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", - "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", - "picomatch": "^2.3.1" + "picomatch": "^4.0.2" }, "engines": { "node": ">=14.0.0" @@ -2493,205 +3085,338 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", - "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", - "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", - "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", - "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", - "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", - "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", - "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", - "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", - "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", - "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", - "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ - "s390x" + "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", - "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", - "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ - "x64" + "arm64" ], + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", - "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", - "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", - "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@shikijs/core": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.1.tgz", - "integrity": "sha512-vWvqi9JNgz1dRL9Nvog5wtx7RuNkf7MEPl2mU/cyUUxJeH1CAr3t+81h8zO8zs7DK6cKLMoU9TvukWIDjP4Lzg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.0.1", - "@shikijs/types": "4.0.1", + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" @@ -2701,12 +3426,12 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.1.tgz", - "integrity": "sha512-DJK9NiwtGYqMuKCRO4Ip0FKNDQpmaiS+K5bFjJ7DWFn4zHueDWgaUG8kAofkrnXF6zPPYYQY7J5FYVW9MbZyBg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.1", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" }, @@ -2715,12 +3440,12 @@ } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.1.tgz", - "integrity": "sha512-oCWdCTDch3J8Kc0OZJ98KuUPC02O1VqIE3W/e2uvrHqTxYRR21RGEJMtchrgrxhsoJJCzmIciKsqG+q/yD+Cxg==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.1", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -2728,24 +3453,24 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.1.tgz", - "integrity": "sha512-v/mluaybWdnGJR4GqAR6zh8qAZohW9k+cGYT28Y7M8+jLbC0l4yG085O1A+WkseHTn+awd+P3UBymb2+MXFc8w==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.1" + "@shikijs/types": "4.0.2" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.1.tgz", - "integrity": "sha512-ns0hHZc5eWZuvuIEJz2pTx3Qecz0aRVYumVQJ8JgWY2tq/dH8WxdcVM49Fc2NsHEILNIT6vfdW9MF26RANWiTA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.1", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -2754,21 +3479,21 @@ } }, "node_modules/@shikijs/themes": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.1.tgz", - "integrity": "sha512-FW41C/D6j/yKQkzVdjrRPiJCtgeDaYRJFEyCKFCINuRJRj9WcmubhP4KQHPZ4+9eT87jruSrYPyoblNRyDFzvA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.0.1" + "@shikijs/types": "4.0.2" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.1.tgz", - "integrity": "sha512-EaygPEn57+jJ76mw+nTLvIpJMAcMPokFbrF8lufsZP7Ukk+ToJYEcswN1G0e49nUZAq7aCQtoeW219A8HK1ZOw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -2785,29 +3510,37 @@ "license": "MIT" }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" }, "node_modules/@svelte-put/shortcut": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@svelte-put/shortcut/-/shortcut-3.1.1.tgz", "integrity": "sha512-2L5EYTZXiaKvbEelVkg5znxqvfZGZai3m97+cAiUBhLZwXnGtviTDpHxOoZBsqz41szlfRMcamW/8o0+fbW3ZQ==", + "license": "MIT", "peerDependencies": { "svelte": "^3.55.0 || ^4.0.0 || ^5.0.0" } }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", - "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -2818,6 +3551,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.2.tgz", "integrity": "sha512-Mso5xPCA8zgcKrv+QioVlqMZkyUQ5MjDJiEPuG/Z7cV/5tmwV7LmcVWk5tZ+H0NCOV1x12AsoSpt/CwFwuVXMA==", "dev": true, + "license": "MIT", "dependencies": { "import-meta-resolve": "^4.1.0" }, @@ -2829,6 +3563,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-2.1.2.tgz", "integrity": "sha512-ZfVY5buBclWHoBT+RbkMUViJGEIZ3IfT/0Hvhlgp+qC3LRZwp+wS1Zsw5dgkB2sFDZXctbLNXJtwlkjSp1mw0g==", + "license": "MIT", "dependencies": { "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-json": "^6.1.0", @@ -2840,9 +3575,9 @@ } }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz", - "integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2850,22 +3585,22 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.22.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.22.4.tgz", - "integrity": "sha512-BXK9hTbP8AeQIfoz6+P3uoyVYStVHc5CIKqoTSF7hXm3Q5P9BwFMdEus4jsQuhaYmXGHzukcGlxe2QrsE8BJfQ==", + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.1.0", + "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", - "sade": "^1.8.1", - "set-cookie-parser": "^2.6.0", + "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "bin": { @@ -2875,9 +3610,19 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } } }, "node_modules/@sveltejs/svelte-virtual-list": { @@ -2924,57 +3669,12 @@ "vite": "^5.0.0" } }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/@sveltejs/vite-plugin-svelte/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@sveltejs/vite-plugin-svelte/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -2990,61 +3690,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.6.tgz", - "integrity": "sha512-jb6E0WeSq7OQbVYcIJ6LxnZTeC4HjMvbzFBMCrQff4R50HBlo/obmYNk6V2GCUXDeqiXtvtrQgcIbT+/boB03Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", "dev": true, "license": "MIT", "dependencies": { - "enhanced-resolve": "^5.18.0", - "jiti": "^2.4.2", - "tailwindcss": "4.0.6" - } - }, - "node_modules/@tailwindcss/node/node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" } }, - "node_modules/@tailwindcss/node/node_modules/tailwindcss": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.6.tgz", - "integrity": "sha512-mysewHYJKaXgNOW6pp5xon/emCsfAMnO8WMaGKZZ35fomnR/T5gYnRg2/yRTTrtXiEl1tiVkeRt0eMO6HxEZqw==", - "dev": true, - "license": "MIT" - }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.6.tgz", - "integrity": "sha512-lVyKV2y58UE9CeKVcYykULe9QaE1dtKdxDEdrTPIdbzRgBk6bdxHNAoDqvcqXbIGXubn3VOl1O/CFF77v/EqSA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.6", - "@tailwindcss/oxide-darwin-arm64": "4.0.6", - "@tailwindcss/oxide-darwin-x64": "4.0.6", - "@tailwindcss/oxide-freebsd-x64": "4.0.6", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.6", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.6", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.6", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.6", - "@tailwindcss/oxide-linux-x64-musl": "4.0.6", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.6", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.6" + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.6.tgz", - "integrity": "sha512-xDbym6bDPW3D2XqQqX3PjqW3CKGe1KXH7Fdkc60sX5ZLVUbzPkFeunQaoP+BuYlLc2cC1FoClrIRYnRzof9Sow==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", "cpu": [ "arm64" ], @@ -3055,13 +3743,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.6.tgz", - "integrity": "sha512-1f71/ju/tvyGl5c2bDkchZHy8p8EK/tDHCxlpYJ1hGNvsYihZNurxVpZ0DefpN7cNc9RTT8DjrRoV8xXZKKRjg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", "cpu": [ "arm64" ], @@ -3072,13 +3760,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.6.tgz", - "integrity": "sha512-s/hg/ZPgxFIrGMb0kqyeaqZt505P891buUkSezmrDY6lxv2ixIELAlOcUVTkVh245SeaeEiUVUPiUN37cwoL2g==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", "cpu": [ "x64" ], @@ -3089,13 +3777,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.6.tgz", - "integrity": "sha512-Z3Wo8FWZnmio8+xlcbb7JUo/hqRMSmhQw8IGIRoRJ7GmLR0C+25Wq+bEX/135xe/yEle2lFkhu9JBHd4wZYiig==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", "cpu": [ "x64" ], @@ -3106,13 +3794,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.6.tgz", - "integrity": "sha512-SNSwkkim1myAgmnbHs4EjXsPL7rQbVGtjcok5EaIzkHkCAVK9QBQsWeP2Jm2/JJhq4wdx8tZB9Y7psMzHYWCkA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", "cpu": [ "arm" ], @@ -3123,13 +3811,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.6.tgz", - "integrity": "sha512-tJ+mevtSDMQhKlwCCuhsFEFg058kBiSy4TkoeBG921EfrHKmexOaCyFKYhVXy4JtkaeeOcjJnCLasEeqml4i+Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", "cpu": [ "arm64" ], @@ -3140,13 +3828,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.6.tgz", - "integrity": "sha512-IoArz1vfuTR4rALXMUXI/GWWfx2EaO4gFNtBNkDNOYhlTD4NVEwE45nbBoojYiTulajI4c2XH8UmVEVJTOJKxA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", "cpu": [ "arm64" ], @@ -3157,13 +3845,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.6.tgz", - "integrity": "sha512-QtsUfLkEAeWAC3Owx9Kg+7JdzE+k9drPhwTAXbXugYB9RZUnEWWx5x3q/au6TvUYcL+n0RBqDEO2gucZRvRFgQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", "cpu": [ "x64" ], @@ -3174,13 +3862,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.6.tgz", - "integrity": "sha512-QthvJqIji2KlGNwLcK/PPYo7w1Wsi/8NK0wAtRGbv4eOPdZHkQ9KUk+oCoP20oPO7i2a6X1aBAFQEL7i08nNMA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", "cpu": [ "x64" ], @@ -3191,13 +3879,43 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.6.tgz", - "integrity": "sha512-+oka+dYX8jy9iP00DJ9Y100XsqvbqR5s0yfMZJuPR1H/lDVtDfsZiSix1UFBQ3X1HWxoEEl6iXNJHWd56TocVw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", "cpu": [ "arm64" ], @@ -3208,13 +3926,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.6.tgz", - "integrity": "sha512-+o+juAkik4p8Ue/0LiflQXPmVatl6Av3LEZXpBTfg4qkMIbZdhCGWFzHdt2NjoMiLOJCFDddoV6GYaimvK1Olw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", "cpu": [ "x64" ], @@ -3225,156 +3943,153 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/postcss": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.0.0.tgz", - "integrity": "sha512-lI2bPk4TvwavHdehjr5WiC6HnZ59hacM6ySEo4RM/H7tsjWd8JpqiNW9ThH7rO/yKtrn4mGBoXshpvn8clXjPg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", + "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "^4.0.0", - "@tailwindcss/oxide": "^4.0.0", - "lightningcss": "^1.29.1", - "postcss": "^8.4.41", - "tailwindcss": "4.0.0" + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "postcss": "^8.5.6", + "tailwindcss": "4.2.1" } }, "node_modules/@tailwindcss/typography": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz", - "integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", "dev": true, + "license": "MIT", "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "node_modules/@tiptap/core": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.0.7.tgz", - "integrity": "sha512-/NC0BbekWzi5sC+s7gRrGIv33cUfuiZUG5DWx8TNedA6b6aTFPHUe+2wKRPaPQ0pfGdOWU0nsOkboUJ9dAjl4g==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.2.tgz", + "integrity": "sha512-zKW4LqZt+aNdvz9o4R0/j+D+gfhwzuFItwh7wbqz8g8bWi0jaV95VybeVFVKeg/KGTc3sAa4mm+hGgvgrY+Gvg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.0.7" + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-blockquote": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.0.7.tgz", - "integrity": "sha512-bYJ7r4hYcBZ7GI0LSV0Oxb9rmy/qb0idAf/osvflG2r1tf5CsiW5NYAqlOYAsIVA2OCwXELDlRGCgeKBQ26Kyw==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.2.tgz", + "integrity": "sha512-tkzZzBdwu8pP6pRfYjGanyj4aMSdcr4TS/Z9dcFxA8SYhmBXB4FYTbURME8Eg+n5VIOh1/2c4R2mbOkfQd4GtQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-bold": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.0.7.tgz", - "integrity": "sha512-CQG07yvrIsScLe5NplAuCkVh0sd97Udv1clAGbqfzeV8YfzpV3M7J/Vb09pWyovx3SjDqfsZpkr3RemeKEPY9Q==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.2.tgz", + "integrity": "sha512-NLqh6ewHcDDPveTCL2f6BQcsDI5lubNjiyzvuYr0ZO9AV5Fqw8TkYwoKNijiYlgGRtm+pZLhMnf45gbLJQoymg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.26.1.tgz", - "integrity": "sha512-oHevUcZbTMFOTpdCEo4YEDe044MB4P1ZrWyML8CGe5tnnKdlI9BN03AXpI1mEEa5CA3H1/eEckXx8EiCgYwQ3Q==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.2.tgz", + "integrity": "sha512-Np9jHUBWPnFJMy3Qhup3udARJQnWkbwVxVaHlJdgEy5Hfy/HE/EnItXAxXeFjVZ57cl+kJYamdn1t8VfMOV3mg==", "license": "MIT", "dependencies": { - "tippy.js": "^6.3.7" + "@floating-ui/dom": "^1.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.0.7.tgz", - "integrity": "sha512-9gPc3Tw2Bw7qKLbyW0s05YntE77127pOXQXcclB4I3MXAuz/K03f+DGuSRhOq9K2Oo86BPHdL5I9Ap9cmuS0Tg==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.2.tgz", + "integrity": "sha512-LHmp945at3YYl2VPIg0bopyJioi52xK+YRurOz8A440EgCdnAkFa0UDGHxK/e4Y0R2y3xbPl+VBl3HzZjXPFuw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.0.7" + "@tiptap/extension-list": "^3.20.2" } }, "node_modules/@tiptap/extension-code": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.0.7.tgz", - "integrity": "sha512-6wdUqtXbnIuyKR7xteF2UCnsW2dLNtBKxWvAiOweA7L41HYvburh/tjbkffkNc5KP2XsKzdGbygpunwJMPj6+A==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.2.tgz", + "integrity": "sha512-4mwWtt88Cl7PT5IbQpwigPBlNmB3JUiOchPXJIfbGu7wUxAk7a37vhn8ptiM2IGKpBFum/1PZFUI+Ik7TLIZLg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-code-block": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.0.7.tgz", - "integrity": "sha512-WifMv7N1G1Fnd2oZ+g80FjBpV/eI/fxHKCK3hw03l8LoWgeFaU/6LC93qTV6idkfia3YwiA6WnuyOqlI0FSZ9A==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.2.tgz", + "integrity": "sha512-BpClHuUrOYArL8skifo6RSlBiAVDYkGkq22zVb9lNfrrRqJIlPhwDI8tCZh1sHbgDQPukb4lE5VMPnsEv5M1tw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-code-block-lowlight": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.0.7.tgz", - "integrity": "sha512-y1sHjzxpYqIKikdT5y5ajCOw4hDIPGjPpIBP7x7iw7jyt8a/w/bI8ozUk4epLBpgOvvAwmdIqi7eV7ORMvQaGQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.20.2.tgz", + "integrity": "sha512-9/HZyXlKWD+MoxJX0TvIRWxOuN3UO0gNbyQrQlxKjT/3V3rmSqkzVs1XLBFYbR87LCovD9HwZNM30r4y6s4eYw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/extension-code-block": "^3.0.7", - "@tiptap/pm": "^3.0.7", + "@tiptap/core": "^3.20.2", + "@tiptap/extension-code-block": "^3.20.2", + "@tiptap/pm": "^3.20.2", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "node_modules/@tiptap/extension-collaboration": { - "version": "3.20.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.20.1.tgz", - "integrity": "sha512-JnwLvyzrutBffHp6YPnf0XyTnhAgqZ9D8JSUKFp0edvai+dxsb+UMlawesBrgAuoQXw3B8YZUo2VFDVdKas1xQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.20.2.tgz", + "integrity": "sha512-VV15cmp2PrqsBYJLiV8LyWU9HmbBdVXNx2XotTCGrz6FWKfcj2N2UN2cjw+HB5/rJiMHa0nQU3oDEKlh4XjSHg==", "license": "MIT", "peer": true, "funding": { @@ -3382,29 +4097,29 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.1", - "@tiptap/pm": "^3.20.1", + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2", "@tiptap/y-tiptap": "^3.0.2", "yjs": "^13" } }, "node_modules/@tiptap/extension-document": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.0.7.tgz", - "integrity": "sha512-HJg1nPPZ9fv5oEMwpONeIfT0FjTrgNGuGAat/hgcBi/R2GUNir2/PM/3d6y8QtkR/EgkgcFakCc9azySXLmyUQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.2.tgz", + "integrity": "sha512-HHlpUs1Y22YwDmJ0cmTGPrFPuHk8Q2wvYZeG5eFOEeBu7t4IiCU114slvIR+yrDZrzpPmwzMb8H71scR+moizg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-drag-handle": { - "version": "3.4.5", - "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz", - "integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.20.2.tgz", + "integrity": "sha512-P6gFl5aw5E7tVg7hkkeQ8A/D8yozj03lx05AApeQjhqPK3MTl/5xkHPUsjeLAbVvsr00Mdddow+dReYVEtb70A==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13" @@ -3414,226 +4129,224 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.4.5", - "@tiptap/extension-collaboration": "^3.4.5", - "@tiptap/extension-node-range": "^3.4.5", - "@tiptap/pm": "^3.4.5", - "@tiptap/y-tiptap": "^3.0.0-beta.3" + "@tiptap/core": "^3.20.2", + "@tiptap/extension-collaboration": "^3.20.2", + "@tiptap/extension-node-range": "^3.20.2", + "@tiptap/pm": "^3.20.2", + "@tiptap/y-tiptap": "^3.0.2" } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.0.7.tgz", - "integrity": "sha512-0i2XWdRgYbj6PEPC+pMcGiF/hwg0jl+MavPt1733qWzoDqMEls9cEBTQ9S4HS0TI/jbN/kNavTQ5LlI33kWrww==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.2.tgz", + "integrity": "sha512-LpBZOOgTrFWkYneOWOd0xyB7HUGIZqrgEhL+Beohzxkx63uNRC3PxFAAXhju6wxcvQ49e/WMg++Z8EDwHb6f2Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.0.7" + "@tiptap/extensions": "^3.20.2" } }, "node_modules/@tiptap/extension-file-handler": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-file-handler/-/extension-file-handler-3.0.7.tgz", - "integrity": "sha512-eNJOqLaM91erqm6W7k+ocG09fuiVI4B+adWhv97sFim9TboF0sEIWEYdl68z06N1/+tXv6w8S4zUYQCOzxlVtw==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-file-handler/-/extension-file-handler-3.20.2.tgz", + "integrity": "sha512-+Jwqe1jKWPTH96/xaXZrwZxDgVm/8bcartLlkE06DN6T7WzoPHnMUlb5whxRlgNNDIwVmzVXvlFKbgkPBxCv/g==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/extension-text-style": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.20.2", + "@tiptap/extension-text-style": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "2.26.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.26.1.tgz", - "integrity": "sha512-OJF+H6qhQogVTMedAGSWuoL1RPe3LZYXONuFCVyzHnvvMpK+BP1vm180E2zDNFnn/DVA+FOrzNGpZW7YjoFH1w==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.2.tgz", + "integrity": "sha512-Ev9QuNmV/A2fMuu+XpEy1W+u8FOu75S7GVPtS+cYRQc/TYTKaxha0+j0eYvJzKLzKRguJNRlmPBDHGN7MnSY/w==", "license": "MIT", - "dependencies": { - "tippy.js": "^6.3.7" - }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.0.7.tgz", - "integrity": "sha512-F4ERd5r59WHbY0ALBbrJ/2z9dl+7VSmsMV/ZkzTgq0TZV9KKz3SsCFcCdIZEYzRCEp69/yYtkTofN10xIa+J6A==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.2.tgz", + "integrity": "sha512-IfQuD5XctZa+Xxy3mdjo9NTYbiMFqGPuzyh2ypHUqyuvIwxOIRhxTFaCijOGVYn1g3BH8nzGMhZ5rnZ48zIb6Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extensions": "^3.0.7" + "@tiptap/extensions": "^3.20.2" } }, "node_modules/@tiptap/extension-hard-break": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.0.7.tgz", - "integrity": "sha512-OWrFrKp9PDs9nKJRmyPX22YoscqmoW25VZYeUfvNcAYtI84xYz871s1JmLZkpxqOyI9TafUADFiaRISDnX5EcA==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.2.tgz", + "integrity": "sha512-TdjJ54483D1PsGLOBQBvzTqIKc027hixP/xFul9KfXIpGG+YGqX0U2RO3oUyuv32fbU1ZVLMDfEBzR/ropsyDg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-heading": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.0.7.tgz", - "integrity": "sha512-uS7fFcilFuzKEvhUgndELqlGweD+nZeLOb6oqUE5hM49vECjM7qVjVQnlhV+MH2W1w8eD08cn1lu6lDxaMOe5w==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.2.tgz", + "integrity": "sha512-XKpSEMcER00yfMXiASPpCHHa4Tw6G78AUELFt2PiS0tTWdxNpXZ8y29glyR4LO5eBxGjF1jncO49T1DzDh48TQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-highlight": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.3.0.tgz", - "integrity": "sha512-G+mHVXkoQ4uG97JRFN56qL42iJVKbSeWgDGssmnjNZN/W4Nsc40LuNryNbQUOM9CJbEMIT5NGAwvc/RG0OpGGQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.20.2.tgz", + "integrity": "sha512-bKYXnGlXXwoEYAByws6VNsm0720YRe2nRMZr+WnwTlyaOzUyaGl1GKYmsbsZaOsZm9VPWKiu0T7y4UMjLvweSA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.3.0" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.0.7.tgz", - "integrity": "sha512-m0r4tzfVX3r0ZD7uvDf/GAiVr7lJjYwhZHC+M+JMhYXVI6eB9OXXzhdOIsw9W5QcmhCBaqU+VuPKUusTn4TKLg==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.2.tgz", + "integrity": "sha512-1Ds1xwl4XKWzXhZ8jG+G04BepExxuwtJuw+xbdMXkTD3YDE8KmbSrUIiLpA60Zq4qZmWIrX57F7mOBGaExyfCw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-image": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.0.7.tgz", - "integrity": "sha512-hs6TiSmefwvAqxwhy4+ZFCbmAXiAeWq4v5Zd65kQ7dvN7epeV0NM7ME5su/oscQgoKvNAy1r/4sJVaTnHomYMQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.2.tgz", + "integrity": "sha512-STo7T3NQ1TcF93NXRQDhb5YkepBRpYHY54yfBUmHl5cygYZzOMaGlM0nh8NeX54mh3wJ6+nxpApuM3Jbmg0I+w==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-italic": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.0.7.tgz", - "integrity": "sha512-L05cehSOd7iZWI/igPb90TgQ6RKk2UuuYdatmXff3QUJpYPYct6abcrMb+CeFKJqE9vaXy46dCQkOuPW+bFwkA==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.2.tgz", + "integrity": "sha512-VAIXeJMx4g6WKqqNm8PYzoFrJaRNKLzLtqUXqYozKxnJLpF2HjsIrnBJV9PM+1FKK2Tic1UuowF4OI/6d162SA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-link": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.0.7.tgz", - "integrity": "sha512-e53MddBSVKpxxQ2JmHfyZQ2VBLwqlZxqwn0DQHFMXyCKTzpdUC0DOtkvrY7OVz6HA3yz29qR+qquQxIxcDPrfg==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.2.tgz", + "integrity": "sha512-vnC72CFMUiCJuAt7Hi4T/hKvbY4DqBjqo9G6dkBfNJHXHmqGiGKvkgzm1m7P/R1EX1XYk8nifeCpW6q2uliFRQ==", "license": "MIT", "dependencies": { - "linkifyjs": "^4.2.0" + "linkifyjs": "^4.3.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-list": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.0.7.tgz", - "integrity": "sha512-rwu5dXRO0YLyxndMHI17PoxK0x0ZaMZKRZflqOy8fSnXNwd3Tdy8/6a9tsmpgO38kOZEYuvMVaeB7J/+UeBVLg==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.2.tgz", + "integrity": "sha512-x9h1fDeaLah60WJTb6517nRbAKcbdBsTpmglqxQ9c827PUOUyiVEAu2o2cFEuOMw6Htvty4obOKBUgYi8EAgDA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-list-item": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.0.7.tgz", - "integrity": "sha512-QfW+dtukl5v6oOA1n4wtAYev5yY78nqc2O8jHGZD18xhqNVerh2xBVIH9wOGHPz4q5Em2Ju7xbqXYl0vg2De+w==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.2.tgz", + "integrity": "sha512-6L+ZKOqD9jTmE313qFkrIk81jbk8v437zRa5Sa0/hyFMbupsNVKZoZZkzrq5vOkbz2oE0WpsFXIjcYaqAwJhyA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.0.7" + "@tiptap/extension-list": "^3.20.2" } }, "node_modules/@tiptap/extension-list-keymap": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.0.7.tgz", - "integrity": "sha512-KJWXsyHU8E6SGmlZMHNjSg+XrkmCncJT2l5QGEjTUjlhqwulu+4psTDRio9tCdtepiasTL7qEekGWAhz9wEgzQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.2.tgz", + "integrity": "sha512-SKU+w93E9+TdSWeoJzjFLDbO+XC/QIRQ+PJ6Jruz1BJ6VXILmyVOw3jBwmiJjLo+5h4MNV2D/IHx1P7BpEkUhQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.0.7" + "@tiptap/extension-list": "^3.20.2" } }, "node_modules/@tiptap/extension-mention": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.0.9.tgz", - "integrity": "sha512-DTQNAQkHZ+7Enlt3KvjqN6eECINlqPpET4Drzwj8Mmz9kMILc87cz3G2cwEKRrS9A1Xn3H3VpWvElWE2Wq9JHw==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.20.2.tgz", + "integrity": "sha512-J6Y+5cPQZxXlA6Jh55NkxFFItN7iNEzEAOYWUzAuCewErqgziDFyswUK2BuoKyG/vkYcwU2nuAq2a3KGYgPKfA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.9", - "@tiptap/pm": "^3.0.9", - "@tiptap/suggestion": "^3.0.9" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2", + "@tiptap/suggestion": "^3.20.2" } }, "node_modules/@tiptap/extension-node-range": { - "version": "3.20.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.20.1.tgz", - "integrity": "sha512-+W/mQJxlkXMcwldWUqwdoR0eniJ1S9cVJoAy2Lkis0NhILZDWVNGKl9J4WFoCOXn8Myr17IllIxRYvAXJJ4FHQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.20.2.tgz", + "integrity": "sha512-jZCs918QlIFYi2jexLgKZnevyz22IBhWnQUHSLD7EfjRq9wOrjhXYkWahMKe7etJyzg+nT5aJLz5eiT12B4oXQ==", "license": "MIT", "peer": true, "funding": { @@ -3641,80 +4354,80 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.1", - "@tiptap/pm": "^3.20.1" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.0.7.tgz", - "integrity": "sha512-F/cbG0vt1cjkoJ4A65E6vpZQizZwnE4gJHKAw3ymDdCoZKYaO4OV1UTo98W/jgryORy/HLO12+hogsRvgRvK9Q==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.2.tgz", + "integrity": "sha512-UmPrnvd9/cGcO2QQaESu57kXmkubxmazQmUTgRU2BiLMEWGPPvxnBbJkM/YYmYPFRCs+OTx+XO9ohCD1xqtQcQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/extension-list": "^3.0.7" + "@tiptap/extension-list": "^3.20.2" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.0.7.tgz", - "integrity": "sha512-1lp+/CbYmm1ZnR6CNlreUIWCNQk0cBzLVgS5R8SKfVyYaXo11qQq6Yq8URLhpuge4yXkPGMhClwCLzJ9D9R+eg==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.2.tgz", + "integrity": "sha512-gTWAUmvCnv7OThFsYdyhacL4TM+sJMC/UeuW+drWaTBbo7dvejzkl4hF5B0ytmF3d/ko1GNr4ldTikYZ2xypMw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-strike": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.0.7.tgz", - "integrity": "sha512-WUCd5CMgS6pg0ZGKXsaxVrnEvO/h6XUehebL0yggAsRKSoGERInR2iLfhU4p1f4zk0cD3ydNLJdqZu0H/MIABw==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.2.tgz", + "integrity": "sha512-Jivcc5Hgw2moIDfVoLEuJumDtw38k2mzEMYt3oZQnvE5d0ttXhWWR0LbLm5LBX3oxlc74I3NSi+Q4ixQHiDtvA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-table": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.0.7.tgz", - "integrity": "sha512-S4tvIgagzWnvXLHfltXucgS9TlBwPcQTjQR4llbxmKHAQM4+e77+NGcXXDcQ7E1TdAp3Tk8xRGerGIP7kjCFRA==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.20.2.tgz", + "integrity": "sha512-kURWr90j3sfuC2AkM73lwoWfeDxj+zWK38gbld58/cHUREoWK9lxFEdeCCZOge6Z97ZQRvqRiEGeQ/X3GmUiYg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/extension-text": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.0.7.tgz", - "integrity": "sha512-yf5dNcPLB5SbQ0cQq8qyjiMj9khx4Y4EJoyrDSAok/9zYM3ULqwTPkTSZ2eW6VX/grJeyBVleeBHk1PjJ7NiVw==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.2.tgz", + "integrity": "sha512-I1rVt6JTi/itgsFqXp+JfxWn9fEewIxiIaaaMUmaCJ6HChQSvYlVWy1B/RixmbCCPaL/FxybEa/Tg9MugyOJYA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-text-style": { - "version": "3.20.1", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.1.tgz", - "integrity": "sha512-3LQU92zX6tzl47EBskkAKeJXd6EWwYmBDE7jbd7InJqnt9NMAcj4DtXtXpI+e6Un5+8yzNjVA+fI5+5cFS3dSg==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.2.tgz", + "integrity": "sha512-jvVRtFdEJNjKgIL8wtMg3W4BJMlKyH9aF2jYNU2rf3jA9GJM0rtqtth3jjFnApZoyINtx6jr4o4Ot6+/5d0XYA==", "license": "MIT", "peer": true, "funding": { @@ -3722,66 +4435,66 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.20.1" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-typography": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.0.7.tgz", - "integrity": "sha512-Oz0EIkq8TDd15aupMYcH2L6izdI/LEO0e7+K+OhljTK5g/sGApLxCDdTlmX2szB9EXbTbOpwLKIEz2bPc3HvBA==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.20.2.tgz", + "integrity": "sha512-aDF9YG2qqw3MR1NuQYvnBiwi0I1SB6oEMlZF4odcvPOdNmbqLtNXz5ap8baPz7XqDn+5B4NC1GMNq8CQeAVA0Q==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-underline": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.0.7.tgz", - "integrity": "sha512-pw2v5kbkovaWaC1G2IxP7g94vmUMlRBzZlCnLEyfFxtGa9LVAsUFlFFWaYJEmq7ZPG/tblWCnFfEZuQqFVd8Sg==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.2.tgz", + "integrity": "sha512-oVszIGkRtg8NLhop/t5kco6suWlDUKW9cqhL6wwd19aLztr+tMU/u8+kPG2cjjYZ+XoMZOoKfkOt7he2iU5/0A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extension-youtube": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-3.0.7.tgz", - "integrity": "sha512-BD4rc7Xoi3O+puXSEArHAbBVu4dhj+9TuuVYzEFgNHI+FN/py9J5AiNf4TXGKBSlMUOYPpODaEROwyGmqAmpuA==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-3.20.2.tgz", + "integrity": "sha512-xks2aPgBDEkDvFq/tnbekJvCPDlAh2Jb8XBFfNhCJc4O5dz5nwALKB713bs1WUAFIVtoXCUqWjf0006TvgGeCA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7" + "@tiptap/core": "^3.20.2" } }, "node_modules/@tiptap/extensions": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.0.7.tgz", - "integrity": "sha512-GkXX5l7Q/543BKsC14j8M3qT+75ILb7138zy7cZoHm/s1ztV1XTknpEswBZIRZA9n6qq+Wd9g5qkbR879s6xhA==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.2.tgz", + "integrity": "sha512-gzntns6z/kTgwrX89ydc3rNqDsv8D8sAkyl8HE9X+2D9wtdCgNljevIR6MBNcxG7bVm2+XnId1P9YciCZLuefg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/pm": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.0.7.tgz", - "integrity": "sha512-f8PnWjYqbMCxny8cyjbFNeIyeOYLECTa/7gj8DJr53Ns+P94b4kYIt/GkveR5KoOxsbmXi8Uc4mjcR1giQPaIQ==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.2.tgz", + "integrity": "sha512-tEMZlLy/6ms41PQtyjmniZ3tTd8elavd8htjYzHLPSHtz11zaYV9YjnmnwHK8gynhcSES1orO9z1U4nT4ZLpqg==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", @@ -3809,35 +4522,35 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.0.7.tgz", - "integrity": "sha512-oTHZp6GXQQaZfZi8Fh7klH2YUeGq73XPF35CFw41mwdWdUUUms3ipaCKFqUyEYO21JMf3pZylJLxUucx5U7isg==", - "license": "MIT", - "dependencies": { - "@tiptap/core": "^3.0.7", - "@tiptap/extension-blockquote": "^3.0.7", - "@tiptap/extension-bold": "^3.0.7", - "@tiptap/extension-bullet-list": "^3.0.7", - "@tiptap/extension-code": "^3.0.7", - "@tiptap/extension-code-block": "^3.0.7", - "@tiptap/extension-document": "^3.0.7", - "@tiptap/extension-dropcursor": "^3.0.7", - "@tiptap/extension-gapcursor": "^3.0.7", - "@tiptap/extension-hard-break": "^3.0.7", - "@tiptap/extension-heading": "^3.0.7", - "@tiptap/extension-horizontal-rule": "^3.0.7", - "@tiptap/extension-italic": "^3.0.7", - "@tiptap/extension-link": "^3.0.7", - "@tiptap/extension-list": "^3.0.7", - "@tiptap/extension-list-item": "^3.0.7", - "@tiptap/extension-list-keymap": "^3.0.7", - "@tiptap/extension-ordered-list": "^3.0.7", - "@tiptap/extension-paragraph": "^3.0.7", - "@tiptap/extension-strike": "^3.0.7", - "@tiptap/extension-text": "^3.0.7", - "@tiptap/extension-underline": "^3.0.7", - "@tiptap/extensions": "^3.0.7", - "@tiptap/pm": "^3.0.7" + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.2.tgz", + "integrity": "sha512-QQ81QpwecXN3Rg0nWYC1nRCcWxlUtEua1X5j1NYUtY39SScuLbs3mMQ54P+u9ZeX31pzu/kuix5GQ0fw+SApOA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.20.2", + "@tiptap/extension-blockquote": "^3.20.2", + "@tiptap/extension-bold": "^3.20.2", + "@tiptap/extension-bullet-list": "^3.20.2", + "@tiptap/extension-code": "^3.20.2", + "@tiptap/extension-code-block": "^3.20.2", + "@tiptap/extension-document": "^3.20.2", + "@tiptap/extension-dropcursor": "^3.20.2", + "@tiptap/extension-gapcursor": "^3.20.2", + "@tiptap/extension-hard-break": "^3.20.2", + "@tiptap/extension-heading": "^3.20.2", + "@tiptap/extension-horizontal-rule": "^3.20.2", + "@tiptap/extension-italic": "^3.20.2", + "@tiptap/extension-link": "^3.20.2", + "@tiptap/extension-list": "^3.20.2", + "@tiptap/extension-list-item": "^3.20.2", + "@tiptap/extension-list-keymap": "^3.20.2", + "@tiptap/extension-ordered-list": "^3.20.2", + "@tiptap/extension-paragraph": "^3.20.2", + "@tiptap/extension-strike": "^3.20.2", + "@tiptap/extension-text": "^3.20.2", + "@tiptap/extension-underline": "^3.20.2", + "@tiptap/extensions": "^3.20.2", + "@tiptap/pm": "^3.20.2" }, "funding": { "type": "github", @@ -3845,17 +4558,17 @@ } }, "node_modules/@tiptap/suggestion": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz", - "integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==", + "version": "3.20.2", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.20.2.tgz", + "integrity": "sha512-iyYknBAugaaaLDxn7NVjoucF9FYvsGtd4KeJNevAKXxuzMmoQlcU1HwJfFXdfmX2EMX2S5SwcKBjc63qdvLZDQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.4.2", - "@tiptap/pm": "^3.4.2" + "@tiptap/core": "^3.20.2", + "@tiptap/pm": "^3.20.2" } }, "node_modules/@tiptap/y-tiptap": { @@ -3882,7 +4595,8 @@ "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" }, "node_modules/@types/d3": { "version": "7.4.3", @@ -3923,9 +4637,9 @@ } }, "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", "license": "MIT" }, "node_modules/@types/d3-axis": { @@ -3955,7 +4669,8 @@ "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" }, "node_modules/@types/d3-contour": { "version": "3.0.6", @@ -3974,15 +4689,16 @@ "license": "MIT" }, "node_modules/@types/d3-dispatch": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", - "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", "license": "MIT" }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", "dependencies": { "@types/d3-selection": "*" } @@ -4039,6 +4755,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", "dependencies": { "@types/d3-color": "*" } @@ -4083,14 +4800,15 @@ "license": "MIT" }, "node_modules/@types/d3-selection": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz", - "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==" + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" }, "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -4115,9 +4833,10 @@ "license": "MIT" }, "node_modules/@types/d3-transition": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.8.tgz", - "integrity": "sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", "dependencies": { "@types/d3-selection": "*" } @@ -4126,15 +4845,17 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" }, "node_modules/@types/geojson": { "version": "7946.0.16", @@ -4154,12 +4875,14 @@ "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==" + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -4177,20 +4900,23 @@ "node_modules/@types/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/node": { - "version": "20.11.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", - "integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~7.18.0" } }, "node_modules/@types/pako": { @@ -4209,32 +4935,35 @@ "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==" + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/sizzle": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", - "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", - "dev": true + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", + "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", + "dev": true, + "license": "MIT" }, "node_modules/@types/symlink-or-copy": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz", "integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/unist": { "version": "3.0.3", @@ -4247,27 +4976,53 @@ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "@types/node": "*" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz", - "integrity": "sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/type-utils": "8.31.1", - "@typescript-eslint/utils": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4277,23 +5032,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz", - "integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4303,39 +5055,56 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.1.tgz", - "integrity": "sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz", - "integrity": "sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.31.1", - "@typescript-eslint/utils": "8.31.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4345,15 +5114,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.1.tgz", - "integrity": "sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==", - "dev": true, + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4364,20 +5132,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz", - "integrity": "sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/visitor-keys": "8.31.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4387,20 +5156,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz", - "integrity": "sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.31.1", - "@typescript-eslint/types": "8.31.1", - "@typescript-eslint/typescript-estree": "8.31.1" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4410,19 +5179,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.31.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.1.tgz", - "integrity": "sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.31.1", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4433,27 +5202,39 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" }, "node_modules/@ungap/with-resolvers": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@ungap/with-resolvers/-/with-resolvers-0.1.0.tgz", - "integrity": "sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==" + "integrity": "sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==", + "license": "ISC" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } }, "node_modules/@vitest/expect": { "version": "1.6.1", @@ -4501,10 +5282,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", - "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "dev": true, "license": "MIT", "engines": { @@ -4529,6 +5317,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/spy": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", @@ -4586,7 +5381,14 @@ "node_modules/@webreflection/fetch": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz", - "integrity": "sha512-zCcqCJoNLvdeF41asAK71XPlwSPieeRDsE09albBunJEksuYPYNillKNQjf8p5BqSoTKTuKrW3lUm3MNodUC4g==" + "integrity": "sha512-zCcqCJoNLvdeF41asAK71XPlwSPieeRDsE09albBunJEksuYPYNillKNQjf8p5BqSoTKTuKrW3lUm3MNodUC4g==", + "license": "MIT" + }, + "node_modules/@webreflection/idb-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@webreflection/idb-map/-/idb-map-0.3.2.tgz", + "integrity": "sha512-VLBTx6EUYF/dPdLyyjWWKxQmTWnVXTT1YJekrJUmfGxBcqEVL0Ih2EQptNG/JezkTYgJ0uSTb0yAum/THltBvQ==", + "license": "MIT" }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", @@ -4619,22 +5421,24 @@ ] }, "node_modules/@xyflow/svelte": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.1.19.tgz", - "integrity": "sha512-yW5w5aI+Yqkob4kLQpVDo/ZmX+E9Pw7459kqwLfv4YG4N1NYXrsDRh9cyph/rapbuDnPi6zqK5E8LKrgaCQC0w==", + "version": "0.1.39", + "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-0.1.39.tgz", + "integrity": "sha512-QZ5mzNysvJeJW7DxmqI4Urhhef9tclqtPr7WAS5zQF5Gk6k9INwzey4CYNtEZo8XMj9H8lzgoJRmgMPnJEc1kw==", + "license": "MIT", "dependencies": { - "@svelte-put/shortcut": "^3.1.0", - "@xyflow/system": "0.0.42", + "@svelte-put/shortcut": "3.1.1", + "@xyflow/system": "0.0.59", "classcat": "^5.0.4" }, "peerDependencies": { - "svelte": "^3.0.0 || ^4.0.0" + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" } }, "node_modules/@xyflow/system": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.42.tgz", - "integrity": "sha512-kWYj+Y0GOct0jKYTdyRMNOLPxGNbb2TYvPg2gTmJnZ31DOOMkL5uRBLX825DR2gOACDu+i5FHLxPJUPf/eGOJw==", + "version": "0.0.59", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.59.tgz", + "integrity": "sha512-+xgqYhoBv5F10TQx0SiKZR/DcWtuxFYR+e/LluHb7DMtX4SsMDutZWEJ4da4fDco25jZxw5G9fOlmk7MWvYd5Q==", + "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-selection": "^3.0.10", @@ -4646,9 +5450,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4662,15 +5466,20 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, "engines": { "node": ">=0.4.0" } @@ -4689,6 +5498,7 @@ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", "dev": true, + "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -4698,10 +5508,11 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4714,9 +5525,9 @@ } }, "node_modules/alpinejs": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz", - "integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==", + "version": "3.15.8", + "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.8.tgz", + "integrity": "sha512-zxIfCRTBGvF1CCLIOMQOxAyBuqibxSEwS6Jm1a3HGA9rgrJVcjEWlwLcQTVGAWGS8YhAsTRLVrtQ5a5QT9bSSQ==", "license": "MIT", "dependencies": { "@vue/reactivity": "~3.1.1" @@ -4726,6 +5537,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz", "integrity": "sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==", + "license": "MIT", "dependencies": { "bezier-easing": "^2.0.3" } @@ -4735,6 +5547,7 @@ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -4744,6 +5557,7 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -4759,6 +5573,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -4770,6 +5585,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -4778,6 +5595,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -4792,6 +5611,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -4800,6 +5620,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arch": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", @@ -4818,17 +5650,19 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -4839,6 +5673,7 @@ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": "~2.1.0" } @@ -4848,6 +5683,7 @@ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8" } @@ -4867,26 +5703,30 @@ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true, + "license": "ISC", "engines": { "node": ">= 4.0.0" } @@ -4896,6 +5736,7 @@ "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "*" } @@ -4904,7 +5745,8 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/axobject-query": { "version": "4.1.0", @@ -4915,17 +5757,45 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bare-events": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.2.2.tgz", - "integrity": "sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, - "optional": true + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/base64-arraybuffer": { "version": "1.0.2", @@ -4953,18 +5823,21 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/basic-devtools": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/basic-devtools/-/basic-devtools-0.1.6.tgz", - "integrity": "sha512-g9zJ63GmdUesS3/Fwv0B5SYX6nR56TQXmGr+wE5PRTNCnGQMYWhUx/nZB/mMWnQJVLPPAp89oxDNlasdtNkW5Q==" + "integrity": "sha512-g9zJ63GmdUesS3/Fwv0B5SYX6nR56TQXmGr+wE5PRTNCnGQMYWhUx/nZB/mMWnQJVLPPAp89oxDNlasdtNkW5Q==", + "license": "ISC" }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "tweetnacl": "^0.14.3" } @@ -4972,12 +5845,14 @@ "node_modules/bezier-easing": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", - "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==" + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", "engines": { "node": ">=8" }, @@ -4986,37 +5861,27 @@ } }, "node_modules/bits-ui": { - "version": "0.21.15", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-0.21.15.tgz", - "integrity": "sha512-+m5WSpJnFdCcNdXSTIVC1WYBozipO03qRh03GFWgrdxoHiolCfwW71EYG4LPCWYPG6KcTZV0Cj6iHSiZ7cdKdg==", + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.3.tgz", + "integrity": "sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==", "license": "MIT", "dependencies": { - "@internationalized/date": "^3.5.1", - "@melt-ui/svelte": "0.76.2", - "nanoid": "^5.0.5" + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/huntabyte" }, "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.118" - } - }, - "node_modules/bits-ui/node_modules/@melt-ui/svelte": { - "version": "0.76.2", - "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.2.tgz", - "integrity": "sha512-7SbOa11tXUS95T3fReL+dwDs5FyJtCEqrqG3inRziDws346SYLsxOQ6HmX+4BkIsQh1R8U3XNa+EMmdMt38lMA==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.3.1", - "@floating-ui/dom": "^1.4.5", - "@internationalized/date": "^3.5.0", - "dequal": "^2.0.3", - "focus-trap": "^7.5.2", - "nanoid": "^5.0.4" - }, - "peerDependencies": { - "svelte": ">=3 <5" + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" } }, "node_modules/bl": { @@ -5024,17 +5889,44 @@ "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", "dev": true, + "license": "MIT", "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, + "node_modules/bl/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bl/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, + "license": "MIT", "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -5048,13 +5940,15 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/boolbase": { "version": "1.0.0", @@ -5063,19 +5957,31 @@ "dev": true, "license": "ISC" }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", "dependencies": { "fill-range": "^7.1.1" }, @@ -5087,13 +5993,15 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/broccoli-node-api/-/broccoli-node-api-1.7.0.tgz", "integrity": "sha512-QIqLSVJWJUVOhclmkmypJJH9u9s/aWH4+FH6Q6Ju5l+Io4dtwqdPUNmDfw40o6sxhbZHhqGujDJuHTML1wG8Yw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/broccoli-node-info": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/broccoli-node-info/-/broccoli-node-info-2.2.0.tgz", "integrity": "sha512-VabSGRpKIzpmC+r+tJueCE5h8k6vON7EIMMWu6d/FyPdtijwLQ7QvzShEw+m3mHoDzUaj/kiZsDYrS8X2adsBg==", "dev": true, + "license": "MIT", "engines": { "node": "8.* || >= 10.*" } @@ -5103,6 +6011,7 @@ "resolved": "https://registry.npmjs.org/broccoli-output-wrapper/-/broccoli-output-wrapper-3.2.5.tgz", "integrity": "sha512-bQAtwjSrF4Nu0CK0JOy5OZqw9t5U0zzv2555EA/cF8/a8SLDTIetk9UgrtMVw7qKLKdSpOZ2liZNeZZDaKgayw==", "dev": true, + "license": "MIT", "dependencies": { "fs-extra": "^8.1.0", "heimdalljs-logger": "^0.1.10", @@ -5117,6 +6026,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", @@ -5131,6 +6041,7 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, + "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -5140,6 +6051,7 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4.0.0" } @@ -5149,6 +6061,7 @@ "resolved": "https://registry.npmjs.org/broccoli-plugin/-/broccoli-plugin-4.0.7.tgz", "integrity": "sha512-a4zUsWtA1uns1K7p9rExYVYG99rdKeGRymW0qOCNkvDPHQxVi3yVyJHhQbM3EZwdt2E0mnhr5e0c/bPpJ7p3Wg==", "dev": true, + "license": "MIT", "dependencies": { "broccoli-node-api": "^1.7.0", "broccoli-output-wrapper": "^3.2.5", @@ -5163,9 +6076,9 @@ } }, "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, "funding": [ { @@ -5181,38 +6094,22 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "ieee754": "^1.1.13" } }, - "node_modules/buffer-builder": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", - "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", - "devOptional": true, - "license": "MIT/X11" - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" } }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -5228,41 +6125,40 @@ "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, + "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/callsites": { @@ -5270,6 +6166,7 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5288,24 +6185,18 @@ "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", - "svg-pathdata": "^6.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/canvg/node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/ccount": { "version": "2.0.1", @@ -5354,6 +6245,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5365,6 +6257,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -5386,9 +6291,9 @@ } }, "node_modules/chart.js": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", - "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -5415,31 +6320,32 @@ "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/cheerio": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", - "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", "dev": true, "license": "MIT", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "encoding-sniffer": "^0.2.0", - "htmlparser2": "^9.1.0", - "parse5": "^7.1.2", - "parse5-htmlparser2-tree-adapter": "^7.0.0", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", - "undici": "^6.19.5", + "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">=18.17" + "node": ">=20.18.1" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -5463,28 +6369,18 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cheerio/node_modules/undici": { - "version": "6.21.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz", - "integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/chevrotain": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", - "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.0.3", - "@chevrotain/gast": "11.0.3", - "@chevrotain/regexp-to-ast": "11.0.3", - "@chevrotain/types": "11.0.3", - "@chevrotain/utils": "11.0.3", - "lodash-es": "4.17.21" + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" } }, "node_modules/chevrotain-allstar": { @@ -5500,37 +6396,19 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" } }, "node_modules/chownr": { @@ -5543,9 +6421,9 @@ } }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -5553,6 +6431,7 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } @@ -5560,13 +6439,15 @@ "node_modules/classcat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5576,6 +6457,7 @@ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, + "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" }, @@ -5584,10 +6466,11 @@ } }, "node_modules/cli-table3": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.4.tgz", - "integrity": "sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.0" }, @@ -5598,31 +6481,12 @@ "@colors/colors": "1.5.0" } }, - "node_modules/cli-table3/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/cli-table3/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", "dev": true, + "license": "MIT", "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" @@ -5634,26 +6498,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -5693,9 +6537,9 @@ } }, "node_modules/cliui/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/cliui/node_modules/string-width": { @@ -5716,12 +6560,12 @@ } }, "node_modules/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -5752,16 +6596,11 @@ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8" } }, - "node_modules/clone-stats": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", - "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", - "dev": true - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5775,14 +6614,16 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/codedent/-/codedent-0.1.2.tgz", "integrity": "sha512-qEqzcy5viM3UoCN0jYHZeXZoyd4NZQzYFg0kOBj8O1CgoGG9WYYTF+VeQRsN0OSKFjF3G1u4WDUOtOsWEx6N2w==", + "license": "ISC", "dependencies": { "plain-tag": "^0.1.3" } }, "node_modules/codemirror": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", - "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", @@ -5794,9 +6635,10 @@ } }, "node_modules/codemirror-lang-elixir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/codemirror-lang-elixir/-/codemirror-lang-elixir-4.0.0.tgz", - "integrity": "sha512-mzFesxo/t6KOxwnkqVd34R/q7yk+sMtHh6vUKGAvjwHmpL7bERHB+vQAsmU/nqrndkwVeJEHWGw/z/ybfdiudA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/codemirror-lang-elixir/-/codemirror-lang-elixir-4.0.1.tgz", + "integrity": "sha512-z6W/XB4b7TZrp9EZYBGVq93vQfvKbff+1iM8YZaVErL0dguBAeLmVRlEv1NuDZHOP1qjJ3NwyibkUkNWn7q9VQ==", + "license": "Apache-2.0", "dependencies": { "@codemirror/language": "^6.0.0", "lezer-elixir": "^1.0.0" @@ -5826,6 +6668,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz", "integrity": "sha512-Uxz3BMTWIslzeWjuQnizGWVg0j6khbvHUQ8+5BdM7WuJEm4ALXwq3wluYoB+uF68uPBz/oUOeJnYURKyfjexlA==", + "license": "ISC", "dependencies": { "@ungap/structured-clone": "^1.2.0", "@ungap/with-resolvers": "^0.1.0", @@ -5836,22 +6679,12 @@ "ws": "^8.16.0" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -5862,22 +6695,16 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/colorjs.io": { "version": "0.5.2", @@ -5891,6 +6718,7 @@ "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.1.90" } @@ -5900,6 +6728,7 @@ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5918,12 +6747,13 @@ } }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 6" } }, "node_modules/common-tags": { @@ -5931,6 +6761,7 @@ "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4.0.0" } @@ -5938,13 +6769,15 @@ "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/confbox": { "version": "0.1.8", @@ -5956,7 +6789,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cookie": { "version": "0.6.0", @@ -5968,9 +6802,9 @@ } }, "node_modules/core-js": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.41.0.tgz", - "integrity": "sha512-SJ4/EHwS36QMJd6h/Rg+GyR4A5xE0FSI3eZ+iBVpfqf1x0eTSg1smWLHrA+2jQThZSh97fmSgFSU8B61nxosxA==", + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5982,12 +6816,14 @@ "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" }, "node_modules/cose-base": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", "dependencies": { "layout-base": "^1.0.0" } @@ -5996,6 +6832,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", "bin": { "crc32": "bin/crc32.njs" }, @@ -6006,12 +6843,14 @@ "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -6032,9 +6871,9 @@ } }, "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6049,9 +6888,9 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -6066,137 +6905,77 @@ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, + "license": "MIT", "bin": { "cssesc": "bin/cssesc" }, "engines": { - "node": ">=4" - } - }, - "node_modules/cypress": { - "version": "13.15.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.15.0.tgz", - "integrity": "sha512-53aO7PwOfi604qzOkCSzNlWquCynLlKE/rmmpSPcziRH6LNfaDUAklQT6WJIsD8ywxlIy+uVZsnTMCCQVd2kTw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@cypress/request": "^3.0.4", - "@cypress/xvfb": "^1.2.4", - "@types/sinonjs__fake-timers": "8.1.1", - "@types/sizzle": "^2.3.2", - "arch": "^2.2.0", - "blob-util": "^2.0.2", - "bluebird": "^3.7.2", - "buffer": "^5.7.1", - "cachedir": "^2.3.0", - "chalk": "^4.1.0", - "check-more-types": "^2.24.0", - "cli-cursor": "^3.1.0", - "cli-table3": "~0.6.1", - "commander": "^6.2.1", - "common-tags": "^1.8.0", - "dayjs": "^1.10.4", - "debug": "^4.3.4", - "enquirer": "^2.3.6", - "eventemitter2": "6.4.7", - "execa": "4.1.0", - "executable": "^4.1.1", - "extract-zip": "2.0.1", - "figures": "^3.2.0", - "fs-extra": "^9.1.0", - "getos": "^3.2.1", - "is-ci": "^3.0.1", - "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", - "listr2": "^3.8.3", - "lodash": "^4.17.21", - "log-symbols": "^4.0.0", - "minimist": "^1.2.8", - "ospath": "^1.2.2", - "pretty-bytes": "^5.6.0", - "process": "^0.11.10", - "proxy-from-env": "1.0.0", - "request-progress": "^3.0.0", - "semver": "^7.5.3", - "supports-color": "^8.1.1", - "tmp": "~0.2.3", - "untildify": "^4.0.0", - "yauzl": "^2.10.0" - }, - "bin": { - "cypress": "bin/cypress" - }, - "engines": { - "node": "^16.0.0 || ^18.0.0 || >=20.0.0" - } - }, - "node_modules/cypress/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/cypress/node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/cypress/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" + "node": ">=4" } }, - "node_modules/cypress/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/cypress": { + "version": "13.17.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.17.0.tgz", + "integrity": "sha512-5xWkaPurwkIljojFidhw8lFScyxhtiFHl/i/3zov+1Z5CmY4t9tjIdvSXfu82Y3w7wt0uR9KkucbhkVvJZLQSA==", "dev": true, + "hasInstallScript": true, + "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@cypress/request": "^3.0.6", + "@cypress/xvfb": "^1.2.4", + "@types/sinonjs__fake-timers": "8.1.1", + "@types/sizzle": "^2.3.2", + "arch": "^2.2.0", + "blob-util": "^2.0.2", + "bluebird": "^3.7.2", + "buffer": "^5.7.1", + "cachedir": "^2.3.0", + "chalk": "^4.1.0", + "check-more-types": "^2.24.0", + "ci-info": "^4.0.0", + "cli-cursor": "^3.1.0", + "cli-table3": "~0.6.1", + "commander": "^6.2.1", + "common-tags": "^1.8.0", + "dayjs": "^1.10.4", + "debug": "^4.3.4", + "enquirer": "^2.3.6", + "eventemitter2": "6.4.7", + "execa": "4.1.0", + "executable": "^4.1.1", + "extract-zip": "2.0.1", + "figures": "^3.2.0", + "fs-extra": "^9.1.0", + "getos": "^3.2.1", + "is-installed-globally": "~0.4.0", + "lazy-ass": "^1.6.0", + "listr2": "^3.8.3", + "lodash": "^4.17.21", + "log-symbols": "^4.0.0", + "minimist": "^1.2.8", + "ospath": "^1.2.2", + "pretty-bytes": "^5.6.0", + "process": "^0.11.10", + "proxy-from-env": "1.0.0", + "request-progress": "^3.0.0", + "semver": "^7.5.3", + "supports-color": "^8.1.1", + "tmp": "~0.2.3", + "tree-kill": "1.2.2", + "untildify": "^4.0.0", + "yauzl": "^2.10.0" }, - "engines": { - "node": ">=10" + "bin": { + "cypress": "bin/cypress" }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "engines": { + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, "node_modules/cytoscape": { - "version": "3.31.2", - "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.31.2.tgz", - "integrity": "sha512-/eOXg2uGdMdpGlEes5Sf6zE+jUG+05f3htFNQIxLxduOH/SsaUZiPBfAwP1btVIVzsnhiNOdi+hvDRLYfMZjGw==", + "version": "3.33.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", + "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", "engines": { "node": ">=0.10" @@ -6206,6 +6985,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", "dependencies": { "cose-base": "^1.0.0" }, @@ -6334,6 +7114,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", "engines": { "node": ">=12" } @@ -6366,6 +7147,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", "engines": { "node": ">=12" } @@ -6374,6 +7156,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" @@ -6420,6 +7203,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", "engines": { "node": ">=12" } @@ -6451,9 +7235,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -6514,6 +7298,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", "dependencies": { "d3-color": "1 - 3" }, @@ -6561,6 +7346,7 @@ "version": "0.12.3", "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" @@ -6570,6 +7356,7 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", "dependencies": { "internmap": "^1.0.0" } @@ -6577,12 +7364,14 @@ "node_modules/d3-sankey/node_modules/d3-path": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" }, "node_modules/d3-sankey/node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", "dependencies": { "d3-path": "1" } @@ -6590,7 +7379,8 @@ "node_modules/d3-sankey/node_modules/internmap": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" }, "node_modules/d3-scale": { "version": "4.0.2", @@ -6625,6 +7415,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", "engines": { "node": ">=12" } @@ -6669,6 +7460,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", "engines": { "node": ">=12" } @@ -6677,6 +7469,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", @@ -6695,6 +7488,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", @@ -6707,9 +7501,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz", - "integrity": "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -6721,6 +7515,7 @@ "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "dev": true, + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0" }, @@ -6729,17 +7524,18 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -6767,12 +7563,14 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -6781,7 +7579,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, + "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -6794,6 +7592,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delaunator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", @@ -6808,6 +7623,7 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -6816,22 +7632,31 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", "engines": { "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, "node_modules/devalue": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", - "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==" + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" }, "node_modules/devlop": { "version": "1.1.0", @@ -6867,6 +7692,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -6919,9 +7745,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", - "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -6969,13 +7795,16 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "dev": true, + "license": "MIT", "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -6998,14 +7827,16 @@ "license": "0BSD" }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" }, "node_modules/encoding-sniffer": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", - "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", "dev": true, "license": "MIT", "dependencies": { @@ -7017,43 +7848,67 @@ } }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, + "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/engine.io-client": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", - "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", + "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.17.1", - "xmlhttprequest-ssl": "~2.0.0" + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/engine.io-parser": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", - "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", "engines": { "node": ">=10.0.0" } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -7064,6 +7919,7 @@ "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -7076,12 +7932,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ensure-posix-path/-/ensure-posix-path-1.1.1.tgz", "integrity": "sha512-VWU0/zXzVbeJNXvME/5EmLuEj2TauvoaTz6aFYK1Z92JCBlDlZ3Gu0tuGR42kpW1754ywTs+QB0g5TP0oj9Zaw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -7093,13 +7951,13 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/eol/-/eol-0.9.1.tgz", "integrity": "sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7109,7 +7967,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -7143,10 +8001,16 @@ "node": ">= 0.4" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, "node_modules/esbuild": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", - "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7157,31 +8021,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.1", - "@esbuild/android-arm": "0.25.1", - "@esbuild/android-arm64": "0.25.1", - "@esbuild/android-x64": "0.25.1", - "@esbuild/darwin-arm64": "0.25.1", - "@esbuild/darwin-x64": "0.25.1", - "@esbuild/freebsd-arm64": "0.25.1", - "@esbuild/freebsd-x64": "0.25.1", - "@esbuild/linux-arm": "0.25.1", - "@esbuild/linux-arm64": "0.25.1", - "@esbuild/linux-ia32": "0.25.1", - "@esbuild/linux-loong64": "0.25.1", - "@esbuild/linux-mips64el": "0.25.1", - "@esbuild/linux-ppc64": "0.25.1", - "@esbuild/linux-riscv64": "0.25.1", - "@esbuild/linux-s390x": "0.25.1", - "@esbuild/linux-x64": "0.25.1", - "@esbuild/netbsd-arm64": "0.25.1", - "@esbuild/netbsd-x64": "0.25.1", - "@esbuild/openbsd-arm64": "0.25.1", - "@esbuild/openbsd-x64": "0.25.1", - "@esbuild/sunos-x64": "0.25.1", - "@esbuild/win32-arm64": "0.25.1", - "@esbuild/win32-ia32": "0.25.1", - "@esbuild/win32-x64": "0.25.1" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escalade": { @@ -7197,6 +8062,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -7205,16 +8071,18 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -7264,6 +8132,7 @@ "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.5.4" }, @@ -7275,10 +8144,11 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, + "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7287,10 +8157,11 @@ } }, "node_modules/eslint-plugin-cypress": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-3.4.0.tgz", - "integrity": "sha512-Rrrr3Ri6wHqzrRr+TyUV7bDS4UnMMrFY1R1PP2F7XdGfe9txDC6lQEshyoNOWqGoPkbbeDm1x1XPc/adxemsnA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-cypress/-/eslint-plugin-cypress-3.6.0.tgz", + "integrity": "sha512-7IAMcBbTVu5LpWeZRn5a9mQ30y4hKp3AfTz+6nSD/x/7YyLMoBI6X7XjDLYI6zFvuy4Q4QVGl563AGEXGW/aSA==", "dev": true, + "license": "MIT", "dependencies": { "globals": "^13.20.0" }, @@ -7334,10 +8205,11 @@ } }, "node_modules/eslint-plugin-svelte/node_modules/postcss-selector-parser": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz", - "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -7351,6 +8223,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -7367,6 +8240,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -7374,6 +8248,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -7385,11 +8266,22 @@ "concat-map": "0.0.1" } }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7408,6 +8300,7 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -7421,10 +8314,11 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -7433,12 +8327,13 @@ } }, "node_modules/esrap": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.1.tgz", - "integrity": "sha512-ebTT9B6lOtZGMgJ3o5r12wBacHctG7oEWazIda8UlPfA3HD/Wrv8FdXoVo73vzdpwCxNyXjPauyN2bbJzMkB9A==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" } }, "node_modules/esrecurse": { @@ -7446,6 +8341,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -7458,6 +8354,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -7465,13 +8362,15 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -7480,12 +8379,24 @@ "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } }, "node_modules/eventsource-parser": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "license": "MIT", "engines": { "node": ">=14.18" } @@ -7495,6 +8406,7 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", @@ -7513,17 +8425,12 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/executable": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", "dev": true, + "license": "MIT", "dependencies": { "pify": "^2.2.0" }, @@ -7531,23 +8438,19 @@ "node": ">=4" } }, - "node_modules/exsolve": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz", - "integrity": "sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw==", - "license": "MIT" - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", @@ -7570,30 +8473,34 @@ "dev": true, "engines": [ "node >=0.6.0" - ] + ], + "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -7603,6 +8510,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -7614,13 +8522,15 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-png": { "version": "6.4.0", @@ -7633,16 +8543,11 @@ "pako": "^2.1.0" } }, - "node_modules/fast-png/node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "license": "(MIT AND Zlib)" - }, "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -7652,10 +8557,29 @@ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "dev": true, + "license": "MIT", "dependencies": { "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -7667,6 +8591,7 @@ "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -7682,6 +8607,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -7691,6 +8617,7 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -7701,12 +8628,14 @@ "node_modules/file-saver": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", - "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -7719,6 +8648,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -7735,6 +8665,7 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -7745,9 +8676,9 @@ } }, "node_modules/flatbuffers": { - "version": "25.1.24", - "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.1.24.tgz", - "integrity": "sha512-Ni+KCqYquU30UEgGkrrwpbYtUcUmNuLFcQ5Xdy9DK7WUaji+AAov+Bf12FEYmu0eI15y31oD38utnBexe0cAYA==", + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", "license": "Apache-2.0" }, "node_modules/flatpickr": { @@ -7757,26 +8688,29 @@ "license": "MIT" }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/focus-trap": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.4.tgz", - "integrity": "sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "license": "MIT", "dependencies": { - "tabbable": "^6.2.0" + "tabbable": "^6.4.0" } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -7786,19 +8720,33 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "*" } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { @@ -7822,16 +8770,19 @@ } }, "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", "dependencies": { + "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" }, "engines": { - "node": ">=14.14" + "node": ">=10" } }, "node_modules/fs-merger": { @@ -7839,6 +8790,7 @@ "resolved": "https://registry.npmjs.org/fs-merger/-/fs-merger-3.2.1.tgz", "integrity": "sha512-AN6sX12liy0JE7C2evclwoo0aCG3PFulLjrTLsJpWh/2mM+DinhpSGqYLbHBBbIW1PLRNcFhJG8Axtz8mQW3ug==", "dev": true, + "license": "MIT", "dependencies": { "broccoli-node-api": "^1.7.0", "broccoli-node-info": "^2.1.0", @@ -7852,6 +8804,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", @@ -7866,6 +8819,7 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, + "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -7875,6 +8829,7 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4.0.0" } @@ -7884,6 +8839,7 @@ "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-2.0.1.tgz", "integrity": "sha512-UTOY+59K6IA94tec8Wjqm0FSh5OVudGNB0NL/P6fB3HiE3bYOY3VYBGijsnOHNkQSwC1FKkU77pmq7xp9CskLw==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.8", "streamx": "^2.12.0" @@ -7897,6 +8853,7 @@ "resolved": "https://registry.npmjs.org/fs-tree-diff/-/fs-tree-diff-2.0.1.tgz", "integrity": "sha512-x+CfAZ/lJHQqwlD64pYM5QxWjzWhSjroaVsr8PW831zOApL55qPibed0c+xebaLWVr2BnHFoHdrwOv8pzt8R5A==", "dev": true, + "license": "MIT", "dependencies": { "@types/symlink-or-copy": "^1.2.0", "heimdalljs-logger": "^0.1.7", @@ -7911,13 +8868,15 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -7930,14 +8889,16 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/fuse.js": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", - "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", "engines": { "node": ">=10" } @@ -7945,7 +8906,8 @@ "node_modules/gc-hook": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/gc-hook/-/gc-hook-0.3.1.tgz", - "integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==" + "integrity": "sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==", + "license": "ISC" }, "node_modules/get-caller-file": { "version": "2.0.5", @@ -7957,9 +8919,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -8022,6 +8984,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, + "license": "MIT", "dependencies": { "pump": "^3.0.0" }, @@ -8037,6 +9000,7 @@ "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", "dev": true, + "license": "MIT", "dependencies": { "async": "^3.2.0" } @@ -8046,6 +9010,7 @@ "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "dev": true, + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0" } @@ -8054,6 +9019,8 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -8073,6 +9040,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -8081,10 +9049,11 @@ } }, "node_modules/glob-stream": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.0.tgz", - "integrity": "sha512-CdIUuwOkYNv9ZadR3jJvap8CMooKziQZ/QCSPhEb7zqfsEI5YnPmvca7IvbaVE3z58ZdUYD2JsU6AUWjL8WZJA==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-8.0.3.tgz", + "integrity": "sha512-fqZVj22LtFJkHODT+M4N1RJQ3TjnnQhfE9GwZI8qXscYarnhpip70poMldRnP8ipQ/w0B621kOhfc53/J9bd/A==", "dev": true, + "license": "MIT", "dependencies": { "@gulpjs/to-absolute-glob": "^4.0.0", "anymatch": "^3.1.3", @@ -8099,10 +9068,26 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -8110,11 +9095,29 @@ "node": ">=10" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", "dev": true, + "license": "MIT", "dependencies": { "ini": "2.0.0" }, @@ -8130,6 +9133,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -8140,11 +9144,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8156,13 +9175,15 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/guid-typescript": { "version": "1.0.9", @@ -8175,6 +9196,7 @@ "resolved": "https://registry.npmjs.org/gulp-sort/-/gulp-sort-2.0.0.tgz", "integrity": "sha512-MyTel3FXOdh1qhw1yKhpimQrAmur9q1X0ZigLmCOxouQD+BD3za9/89O+HfbgBQvvh4igEbp0/PUWO+VqGYG1g==", "dev": true, + "license": "MIT", "dependencies": { "through2": "^2.0.1" } @@ -8190,6 +9212,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "devOptional": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8198,7 +9221,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, + "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" }, @@ -8239,6 +9262,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -8293,6 +9317,7 @@ "resolved": "https://registry.npmjs.org/heimdalljs/-/heimdalljs-0.2.6.tgz", "integrity": "sha512-o9bd30+5vLBvBtzCPwwGqpry2+n0Hi6H1+qwt6y+0kwRHGGF8TFIhJPmnuM0xO97zaKrDZMwO/V56fAnn8m/tA==", "dev": true, + "license": "MIT", "dependencies": { "rsvp": "~3.2.1" } @@ -8302,6 +9327,7 @@ "resolved": "https://registry.npmjs.org/heimdalljs-logger/-/heimdalljs-logger-0.1.10.tgz", "integrity": "sha512-pO++cJbhIufVI/fmB/u2Yty3KJD0TqNPecehFae0/eps0hkZ3b4Zc/PezUMOpYuHFQbA7FxHZxa305EhmjLj4g==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^2.2.0", "heimdalljs": "^0.2.6" @@ -8312,6 +9338,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.0.0" } @@ -8320,13 +9347,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/heimdalljs/node_modules/rsvp": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-3.2.1.tgz", "integrity": "sha512-Rf4YVNYpKjZ6ASAmibcwTNciQ5Co5Ztq6iZPEykHpkoflnD/K5ryE/rHehFsTm4NJj8nKDhbi3eKBWGogmNnkg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/highlight.js": { "version": "11.11.1", @@ -8338,9 +9367,9 @@ } }, "node_modules/html-entities": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.3.tgz", - "integrity": "sha512-D3AfvN7SjhTgBSA8L1BN4FpPzuEd06uy4lHwSoRWr0lndi9BKaNzPLKGOWZ2ocSGguozr08TTb2jhCLHaemruw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", "funding": [ { "type": "github", @@ -8356,7 +9385,8 @@ "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", - "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" }, "node_modules/html-void-elements": { "version": "3.0.0", @@ -8383,9 +9413,9 @@ } }, "node_modules/html2canvas-pro": { - "version": "1.5.11", - "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.11.tgz", - "integrity": "sha512-W4pEeKLG8+9a54RDOSiEKq7gRXXDzt0ORMaLXX+l6a3urSKbmnkmyzcRDCtgTOzmHLaZTLG2wiTQMJqKLlSh3w==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.6.7.tgz", + "integrity": "sha512-BzuCTXx0jf2TfnrJrtjMCY1FR9rxlPdy7yLWt9ZMhZm7Ylaw9MLb7agSheqv2mT/ARduBHDAqvJIFlbxrZfyOA==", "license": "MIT", "dependencies": { "css-line-break": "^2.1.0", @@ -8396,9 +9426,9 @@ } }, "node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -8411,8 +9441,21 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/http-signature": { @@ -8420,6 +9463,7 @@ "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", "dev": true, + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^2.0.2", @@ -8434,14 +9478,15 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=8.12.0" } }, "node_modules/i18next": { - "version": "23.10.1", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.1.tgz", - "integrity": "sha512-NDiIzFbcs3O9PXpfhkjyf7WdqFn5Vq6mhzhtkXzj51aOcNuPNcTwuYNuXCpHsanZGHlHKL35G7huoFeVic1hng==", + "version": "23.16.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", + "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", "funding": [ { "type": "individual", @@ -8456,22 +9501,25 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], + "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" } }, "node_modules/i18next-browser-languagedetector": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.0.tgz", - "integrity": "sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.2.tgz", + "integrity": "sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" } }, "node_modules/i18next-parser": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.3.0.tgz", - "integrity": "sha512-VaQqk/6nLzTFx1MDiCZFtzZXKKyBV6Dv0cJMFM/hOt4/BWHWRgYafzYfVQRUzotwUwjqeNCprWnutzD/YAGczg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/i18next-parser/-/i18next-parser-9.4.0.tgz", + "integrity": "sha512-SLQJGDj/baBIB9ALmJVXSOXWh3Zn9+wH7J2IuQ4rvx8yuQYpUWitmt8cHFjj6FExjgr8dHfd1SGeQgkowXDO1Q==", + "deprecated": "Project is deprecated, use i18next-cli instead", "dev": true, "license": "MIT", "dependencies": { @@ -8502,10 +9550,36 @@ "yarn": ">=1" } }, + "node_modules/i18next-parser/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/i18next-parser/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/i18next-resources-to-backend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.0.tgz", - "integrity": "sha512-8f1l03s+QxDmCfpSXCh9V+AFcxAwIp0UaroWuyOx+hmmv8484GcELHs+lnu54FrNij8cDBEXvEwhzZoXsKcVpg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz", + "integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" } @@ -8514,6 +9588,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -8524,7 +9599,8 @@ "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" }, "node_modules/ieee754": { "version": "1.2.1", @@ -8544,13 +9620,15 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } @@ -8562,17 +9640,18 @@ "license": "MIT" }, "node_modules/immutable": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", - "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "devOptional": true, "license": "MIT" }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -8585,10 +9664,11 @@ } }, "node_modules/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -8599,6 +9679,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -8608,6 +9689,7 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8616,6 +9698,8 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -8624,17 +9708,25 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" }, "node_modules/ini": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -8650,15 +9742,11 @@ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", "license": "MIT" }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" }, @@ -8666,38 +9754,16 @@ "node": ">=8" } }, - "node_modules/is-builtin-module": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", "dependencies": { - "builtin-modules": "^3.3.0" + "hasown": "^2.0.2" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dependencies": { - "hasown": "^2.0.0" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8707,6 +9773,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8715,6 +9782,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8723,6 +9792,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -8735,6 +9805,7 @@ "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", "dev": true, + "license": "MIT", "dependencies": { "global-dirs": "^3.0.0", "is-path-inside": "^3.0.2" @@ -8749,13 +9820,15 @@ "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" }, "node_modules/is-negated-glob": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", "integrity": "sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8764,6 +9837,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -8773,6 +9847,7 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8782,6 +9857,7 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -8793,6 +9869,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", "dependencies": { "@types/estree": "*" } @@ -8802,6 +9879,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -8813,13 +9891,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -8832,6 +9912,7 @@ "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8839,12 +9920,15 @@ "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" }, "node_modules/isomorphic.js": { "version": "0.2.5", @@ -8860,24 +9944,54 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } }, "node_modules/js-sha256": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.10.1.tgz", - "integrity": "sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==" + "integrity": "sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==", + "license": "MIT" }, "node_modules/js-tokens": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz", - "integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==", - "dev": true + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -8889,31 +10003,36 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stringify-pretty-compact": { "version": "4.0.0", @@ -8925,12 +10044,13 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true + "license": "ISC" }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, @@ -8939,19 +10059,19 @@ } }, "node_modules/jspdf": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.0.0.tgz", - "integrity": "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", - "dompurify": "^3.2.4", + "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, @@ -8963,6 +10083,7 @@ "engines": [ "node >=0.6.0" ], + "license": "MIT", "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -8982,10 +10103,16 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/katex": { - "version": "0.16.22", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", - "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "version": "0.16.38", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", + "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -9002,6 +10129,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", "engines": { "node": ">= 12" } @@ -9011,6 +10139,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -9024,6 +10153,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", "engines": { "node": ">=6" } @@ -9036,47 +10166,44 @@ "license": "MIT" }, "node_modules/kokoro-js": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/kokoro-js/-/kokoro-js-1.1.1.tgz", - "integrity": "sha512-cyLO34iI8nBJXPnd3fI4fGeQGS+a6Uatg7eXNL6QS8TLSxaa30WD6Fj7/XoIZYaHg8q6d+TCrui/f74MTY2g1g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/kokoro-js/-/kokoro-js-1.2.1.tgz", + "integrity": "sha512-oq0HZJWis3t8lERkMJh84WLU86dpYD0EuBPtqYnLlQzyFP1OkyBRDcweAqCfhNOpltyN9j/azp1H6uuC47gShw==", "license": "Apache-2.0", "dependencies": { - "@huggingface/transformers": "^3.3.3", + "@huggingface/transformers": "^3.5.1", "phonemizer": "^1.2.1" } }, - "node_modules/kolorist": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", - "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", - "license": "MIT" - }, "node_modules/langium": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", - "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", + "integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==", "license": "MIT", "dependencies": { - "chevrotain": "~11.0.3", - "chevrotain-allstar": "~0.3.0", + "chevrotain": "~11.1.1", + "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", - "vscode-uri": "~3.0.8" + "vscode-uri": "~3.1.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=20.10.0", + "npm": ">=10.2.3" } }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", - "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" }, "node_modules/lazy-ass": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", "dev": true, + "license": "MIT", "engines": { "node": "> 0.8" } @@ -9086,6 +10213,7 @@ "resolved": "https://registry.npmjs.org/lead/-/lead-4.0.0.tgz", "integrity": "sha512-DpMa59o5uGUWWjruMp71e6knmwKU3jRBBn1kjuLWN9EeIOxNeSAwvHf03WIl8g/ZMR2oSQC9ej3yeLBwdDc/pg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.13.0" } @@ -9101,6 +10229,7 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -9110,18 +10239,19 @@ } }, "node_modules/lezer-elixir": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.1.2.tgz", - "integrity": "sha512-K3yPMJcNhqCL6ugr5NkgOC1g37rcOM38XZezO9lBXy0LwWFd8zdWXfmRbY829vZVk0OGCQoI02yDWp9FF2OWZA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/lezer-elixir/-/lezer-elixir-1.1.3.tgz", + "integrity": "sha512-Ymc58/WhxdZS9yEOlnKbF3rdeBdFcPm4OEm26KMqA1Za9vztXi7I5qwGw1KxYmm3Nv0iDHq//EQyBwSEzKG9Mg==", + "license": "Apache-2.0", "dependencies": { "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.3.0" } }, "node_modules/lib0": { - "version": "0.2.109", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.109.tgz", - "integrity": "sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==", + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", "license": "MIT", "dependencies": { "isomorphic.js": "^0.2.4" @@ -9149,13 +10279,13 @@ } }, "node_modules/lightningcss": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.1.tgz", - "integrity": "sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "devOptional": true, "license": "MPL-2.0", "dependencies": { - "detect-libc": "^1.0.3" + "detect-libc": "^2.0.3" }, "engines": { "node": ">= 12.0.0" @@ -9165,22 +10295,43 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.29.1", - "lightningcss-darwin-x64": "1.29.1", - "lightningcss-freebsd-x64": "1.29.1", - "lightningcss-linux-arm-gnueabihf": "1.29.1", - "lightningcss-linux-arm64-gnu": "1.29.1", - "lightningcss-linux-arm64-musl": "1.29.1", - "lightningcss-linux-x64-gnu": "1.29.1", - "lightningcss-linux-x64-musl": "1.29.1", - "lightningcss-win32-arm64-msvc": "1.29.1", - "lightningcss-win32-x64-msvc": "1.29.1" + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.1.tgz", - "integrity": "sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", "cpu": [ "arm64" ], @@ -9198,9 +10349,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.1.tgz", - "integrity": "sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", "cpu": [ "x64" ], @@ -9218,9 +10369,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.1.tgz", - "integrity": "sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", "cpu": [ "x64" ], @@ -9238,9 +10389,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.1.tgz", - "integrity": "sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", "cpu": [ "arm" ], @@ -9258,9 +10409,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.1.tgz", - "integrity": "sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", "cpu": [ "arm64" ], @@ -9278,9 +10429,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.1.tgz", - "integrity": "sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", "cpu": [ "arm64" ], @@ -9298,9 +10449,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.1.tgz", - "integrity": "sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", "cpu": [ "x64" ], @@ -9318,9 +10469,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.1.tgz", - "integrity": "sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", "cpu": [ "x64" ], @@ -9338,9 +10489,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.1.tgz", - "integrity": "sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", "cpu": [ "arm64" ], @@ -9358,9 +10509,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.29.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.1.tgz", - "integrity": "sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", "cpu": [ "x64" ], @@ -9377,19 +10528,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss/node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -9407,6 +10545,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } @@ -9422,6 +10561,7 @@ "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", "dev": true, + "license": "MIT", "dependencies": { "cli-truncate": "^2.1.0", "colorette": "^2.0.16", @@ -9444,51 +10584,15 @@ } } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/listr2/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/local-pkg": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", - "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", "dev": true, + "license": "MIT", "dependencies": { - "mlly": "^1.4.2", - "pkg-types": "^1.0.3" + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" }, "engines": { "node": ">=14" @@ -9500,13 +10604,15 @@ "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -9518,46 +10624,38 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -9574,6 +10672,7 @@ "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^4.3.0", "cli-cursor": "^3.1.0", @@ -9587,17 +10686,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, "node_modules/log-update/node_modules/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -9610,25 +10704,12 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/log-update/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/log-update/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -9639,9 +10720,9 @@ } }, "node_modules/long": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/long/-/long-5.2.4.tgz", - "integrity": "sha512-qtzLbJE8hq7VabR3mISmVGtoXP8KGc2Z/AT8OuqlYD7JTR3oqrgwdjnk07wpj1twXxYmgDXgoKVWUG/fReSzHg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, "node_modules/lop": { @@ -9684,8 +10765,18 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -9696,9 +10787,9 @@ } }, "node_modules/mammoth": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", - "integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", "license": "BSD-2-Clause", "dependencies": { "@xmldom/xmldom": "^0.8.6", @@ -9741,9 +10832,10 @@ "license": "BSD-3-Clause" }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -9760,6 +10852,7 @@ "version": "9.1.6", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "license": "MIT", "bin": { "marked": "bin/marked.js" }, @@ -9767,11 +10860,24 @@ "node": ">= 16" } }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/matcher-collection": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/matcher-collection/-/matcher-collection-2.0.1.tgz", "integrity": "sha512-daE62nS2ZQsDg9raM0IlZzLmI2u+7ZapXBwdoeBUKAYERPDDIc0qNqA8E0Rp2D+gspKR7BgIFP52GeujaGXWeQ==", "dev": true, + "license": "ISC", "dependencies": { "@types/minimatch": "^3.0.3", "minimatch": "^3.0.2" @@ -9780,6 +10886,13 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/matcher-collection/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/matcher-collection/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -9792,10 +10905,11 @@ } }, "node_modules/matcher-collection/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -9837,44 +10951,48 @@ "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/mermaid": { - "version": "11.10.1", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.10.1.tgz", - "integrity": "sha512-0PdeADVWURz7VMAX0+MiMcgfxFKY4aweSGsjgFihe3XlMKNqmai/cugMrqTd3WNHM93V+K+AZL6Wu6tB5HmxRw==", + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz", + "integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==", "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.0.4", - "@iconify/utils": "^2.1.33", - "@mermaid-js/parser": "^0.6.2", + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.0.1", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.11", - "dayjs": "^1.11.13", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", "khroma": "^2.1.0", - "lodash-es": "^4.17.21", - "marked": "^16.0.0", + "lodash-es": "^4.17.23", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -9882,9 +11000,9 @@ } }, "node_modules/mermaid/node_modules/marked": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/marked/-/marked-16.2.1.tgz", - "integrity": "sha512-r3UrXED9lMlHF97jJByry90cwrZBBvZmjG1L68oYfuPMW+uDTnuMbyJDymCWwbTE+f+3LhpNDKfpR3a3saFyjA==", + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -9999,6 +11117,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -10007,11 +11126,24 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -10021,6 +11153,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -10033,20 +11166,22 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -10057,145 +11192,83 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/minizlib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", - "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { - "minipass": "^7.0.4", - "rimraf": "^5.0.5" + "minipass": "^7.1.2" }, "engines": { "node": ">= 18" } }, - "node_modules/minizlib/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minizlib/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/minizlib/node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mktemp": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/mktemp/-/mktemp-0.4.0.tgz", - "integrity": "sha512-IXnMcJ6ZyTuhRmJSjzvHSRhlVPiN9Jwc6e59V0bEJ0ba6OBeX2L0E+mRN1QseeOF4mM+F1Rit6Nh7o+rl2Yn/A==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mktemp/-/mktemp-2.0.2.tgz", + "integrity": "sha512-Q9wJ/xhzeD9Wua1MwDN2v3ah3HENsUVSlzzL9Qw149cL9hHZkXtQGl3Eq36BbdLV+/qUwaP1WtJQ+H/+Oxso8g==", "dev": true, + "license": "MIT", "engines": { - "node": ">0.9" + "node": "20 || 22 || 24" } }, "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", - "license": "MIT", - "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" - } - }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "license": "MIT" - }, - "node_modules/mlly/node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", + "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", "license": "MIT", "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" } }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/nanoid": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", - "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", "funding": [ { "type": "github", @@ -10214,17 +11287,34 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ngraph.events": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.2.2.tgz", - "integrity": "sha512-JsUbEOzANskax+WSYiAPETemLWYXmixuPAlmZmhIbIj6FH/WDgEGCGnRwUQBK0GjOnVm8Ui+e5IJ+5VZ4e32eQ==" + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz", + "integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==", + "license": "BSD-3-Clause" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -10234,6 +11324,7 @@ "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-3.0.0.tgz", "integrity": "sha512-pGO4pzSdaxhWTGkfSfHx3hVzJVslFPwBp2Myq9MYN/ChfJZF87ochMAXnvz6/58RJSf5ik2q9tXprBBrk2cpcg==", "dev": true, + "license": "MIT", "dependencies": { "once": "^1.4.0" }, @@ -10246,6 +11337,7 @@ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.0.0" }, @@ -10271,15 +11363,17 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10287,10 +11381,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", "dependencies": { "wrappy": "1" } @@ -10300,6 +11404,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -10317,26 +11422,26 @@ "license": "MIT" }, "node_modules/oniguruma-to-es": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.4.tgz", - "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", + "integrity": "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==", "license": "MIT", "dependencies": { "oniguruma-parser": "^0.12.1", - "regex": "^6.0.1", + "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "node_modules/onnxruntime-common": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.20.1.tgz", - "integrity": "sha512-YiU0s0IzYYC+gWvqD1HzLc46Du1sXpSiwzKb63PACIJr6LfL27VsXSXQvt68EzD3V0D5Bc0vyJTjmMxp0ylQiw==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", "license": "MIT" }, "node_modules/onnxruntime-node": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.20.1.tgz", - "integrity": "sha512-di/I4HDXRw+FLgq+TyHmQEDd3cEp9iFFZm0r4uJ1Wd7b/WE1VXtKWo8yemex347c6GNF/3Pv86ZfPhIWxORr0w==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", "hasInstallScript": true, "license": "MIT", "os": [ @@ -10345,28 +11450,29 @@ "linux" ], "dependencies": { - "onnxruntime-common": "1.20.1", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", "tar": "^7.0.1" } }, "node_modules/onnxruntime-web": { - "version": "1.21.0-dev.20250206-d981b153d3", - "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.21.0-dev.20250206-d981b153d3.tgz", - "integrity": "sha512-esDVQdRic6J44VBMFLumYvcGfioMh80ceLmzF1yheJyuLKq/Th8VT2aj42XWQst+2bcWnAhw4IKmRQaqzU8ugg==", + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", "license": "MIT", "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", - "onnxruntime-common": "1.21.0-dev.20250206-d981b153d3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { - "version": "1.21.0-dev.20250206-d981b153d3", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0-dev.20250206-d981b153d3.tgz", - "integrity": "sha512-TwaE51xV9q2y8pM61q73rbywJnusw9ivTEHAJ39GVWNZqxCoDBpe/tQkh/w9S+o/g+zS7YeeL0I/2mEWd+dgyA==", + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", "license": "MIT" }, "node_modules/option": { @@ -10376,17 +11482,18 @@ "license": "BSD-2-Clause" }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "license": "MIT", "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -10395,19 +11502,22 @@ "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", - "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==" + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" }, "node_modules/ospath": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -10423,6 +11533,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -10438,6 +11549,7 @@ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", "dev": true, + "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" }, @@ -10452,27 +11564,26 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/package-manager-detector": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", - "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", - "license": "MIT", - "dependencies": { - "quansync": "^0.2.7" - } + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, "node_modules/paneforge": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/paneforge/-/paneforge-0.0.6.tgz", "integrity": "sha512-jYeN/wdREihja5c6nK3S5jritDQ+EbCqC5NrDo97qCZzZ9GkmEcN5C0ZCjF4nmhBwkDKr6tLIgz4QUKWxLXjAw==", + "license": "MIT", "dependencies": { "nanoid": "^5.0.4" }, @@ -10484,6 +11595,7 @@ "version": "9.4.3", "resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.3.tgz", "integrity": "sha512-xaxCpElcRbQsUtIdwlrZA90P90+BHip4Vda2BC8MEb4tkI05PmR6cKECdqUCZ85ZvBHjpI9htJrZBxV5Gp/q/w==", + "license": "MIT", "dependencies": { "amator": "^1.1.0", "ngraph.events": "^1.2.2", @@ -10495,6 +11607,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -10503,13 +11616,13 @@ } }, "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -10542,6 +11655,19 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-data-parser": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", @@ -10553,6 +11679,7 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -10561,6 +11688,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -10569,6 +11697,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -10576,18 +11706,22 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" }, "node_modules/path-posix": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -10600,10 +11734,10 @@ } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" }, "node_modules/pathval": { "version": "1.1.1", @@ -10616,28 +11750,31 @@ } }, "node_modules/pdfjs-dist": { - "version": "5.4.149", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.149.tgz", - "integrity": "sha512-Xe8/1FMJEQPUVSti25AlDpwpUm2QAVmNOpFP0SIahaPIOKBKICaefbzogLdwey3XGGoaP4Lb9wqiw2e9Jqp0LA==", + "version": "5.5.207", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", + "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", "license": "Apache-2.0", "engines": { - "node": ">=20.16.0 || >=22.3.0" + "node": ">=20.19.0 || >=22.13.0 || >=24" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.77" + "@napi-rs/canvas": "^0.1.95", + "node-readable-to-web-readable-stream": "^0.4.2" } }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "devOptional": true + "devOptional": true, + "license": "MIT" }, "node_modules/phonemizer": { "version": "1.2.1", @@ -10646,16 +11783,18 @@ "license": "Apache-2.0" }, "node_modules/picocolors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -10666,25 +11805,27 @@ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/pkg-types": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.1.tgz", - "integrity": "sha512-ko14TjmDuQJ14zsotODv7dBlwxKhUKQEhuhmbqo1uCi9BB0Z2alo/wAXg6q1dTR5TyuqYyWhjtfe/Tsh+X28jQ==", - "dev": true, + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", "dependencies": { - "confbox": "^0.1.7", - "mlly": "^1.7.0", - "pathe": "^1.1.2" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, "node_modules/plain-tag": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/plain-tag/-/plain-tag-0.1.3.tgz", - "integrity": "sha512-yyVAOFKTAElc7KdLt2+UKGExNYwYb/Y/WE9i+1ezCQsJE8gbKSjewfpRqK2nQgZ4d4hhAAGgDCOcIZVilqE5UA==" + "integrity": "sha512-yyVAOFKTAElc7KdLt2+UKGExNYwYb/Y/WE9i+1ezCQsJE8gbKSjewfpRqK2nQgZ4d4hhAAGgDCOcIZVilqE5UA==", + "license": "ISC" }, "node_modules/platform": { "version": "1.3.6", @@ -10709,13 +11850,15 @@ } }, "node_modules/polyscript": { - "version": "0.12.8", - "resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.12.8.tgz", - "integrity": "sha512-kcG3W9jU/s1sYjWOTAa2jAh5D2jm3zJRi+glSTsC+lA3D1b/Sd67pEIGpyL9bWNKYSimqAx4se6jAhQjJZ7+jQ==", + "version": "0.13.10", + "resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.13.10.tgz", + "integrity": "sha512-lRbN48QNfUnUBa81J/0GR4A2FZlB+Qi9m46VE7J8r/Kcx5FopDulT1Z/BFiwUG+xYswUscuVgYND852nq6x2gA==", + "license": "APACHE-2.0", "dependencies": { "@ungap/structured-clone": "^1.2.0", "@ungap/with-resolvers": "^0.1.0", "@webreflection/fetch": "^0.1.5", + "@webreflection/idb-map": "^0.3.1", "basic-devtools": "^0.1.6", "codedent": "^0.1.2", "coincident": "^1.2.3", @@ -10727,9 +11870,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -10744,9 +11887,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -10758,6 +11902,7 @@ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", "dev": true, + "license": "MIT", "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" @@ -10787,6 +11932,7 @@ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -10806,6 +11952,7 @@ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.0" }, @@ -10849,6 +11996,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", "dev": true, + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -10858,9 +12006,9 @@ } }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -10880,15 +12028,17 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -10900,10 +12050,11 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.6.tgz", - "integrity": "sha512-Y1XWLw7vXUQQZmgv1JAEiLcErqUniAF2wO7QJsw8BVMvpLET2dI5WpEIEJx1r11iHVdSMzQxivyfrH9On9t2IQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", "dev": true, + "license": "MIT", "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -10914,6 +12065,7 @@ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -10954,6 +12106,7 @@ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6.0" } @@ -10961,13 +12114,15 @@ "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" }, "node_modules/promise-map-series": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/promise-map-series/-/promise-map-series-0.3.0.tgz", "integrity": "sha512-3npG2NGhTc8BWBolLLf8l/92OxMGaRLbqvIh9wjCHhDXNvk4zsxaTaCpiCunW09qWPrN2zeNSNwRLVBrQQtutA==", "dev": true, + "license": "MIT", "engines": { "node": "10.* || >= 12.*" } @@ -10983,9 +12138,9 @@ } }, "node_modules/prosemirror-changeset": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", - "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", + "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", "license": "MIT", "dependencies": { "prosemirror-transform": "^1.0.0" @@ -11001,9 +12156,9 @@ } }, "node_modules/prosemirror-commands": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.2.tgz", - "integrity": "sha512-0nDHH++qcf/BuPLYvmqZTUUsPJUCPBUXt0J1ErTcDIS369CTp773itzLGIgIXG4LJXOlwYCr44+Mh4ii6MP1QA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -11012,9 +12167,10 @@ } }, "node_modules/prosemirror-dropcursor": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz", - "integrity": "sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", @@ -11025,6 +12181,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/prosemirror-example-setup/-/prosemirror-example-setup-1.2.3.tgz", "integrity": "sha512-+hXZi8+xbFvYM465zZH3rdZ9w7EguVKmUYwYLZjIJIjPK+I0nPTwn8j0ByW2avchVczRwZmOJGNvehblyIerSQ==", + "license": "MIT", "dependencies": { "prosemirror-commands": "^1.0.0", "prosemirror-dropcursor": "^1.0.0", @@ -11038,9 +12195,10 @@ } }, "node_modules/prosemirror-gapcursor": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz", - "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", @@ -11049,9 +12207,10 @@ } }, "node_modules/prosemirror-history": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", - "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", @@ -11060,37 +12219,41 @@ } }, "node_modules/prosemirror-inputrules": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz", - "integrity": "sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "node_modules/prosemirror-keymap": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz", - "integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "node_modules/prosemirror-markdown": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.1.tgz", - "integrity": "sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "license": "MIT", "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", - "prosemirror-model": "^1.20.0" + "prosemirror-model": "^1.25.0" } }, "node_modules/prosemirror-menu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.4.tgz", - "integrity": "sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", + "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", + "license": "MIT", "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", @@ -11099,20 +12262,21 @@ } }, "node_modules/prosemirror-model": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.0.tgz", - "integrity": "sha512-/8XUmxWf0pkj2BmtqZHYJipTBMHIdVjuvFzMvEoxrtyGNmfvdhBiRwYt/eFwy2wA9DtBW3RLqvZnjurEkHaFCw==", + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" } }, "node_modules/prosemirror-schema-basic": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.3.tgz", - "integrity": "sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", "dependencies": { - "prosemirror-model": "^1.19.0" + "prosemirror-model": "^1.25.0" } }, "node_modules/prosemirror-schema-list": { @@ -11127,9 +12291,10 @@ } }, "node_modules/prosemirror-state": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", - "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -11137,16 +12302,16 @@ } }, "node_modules/prosemirror-tables": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz", - "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", "license": "MIT", "dependencies": { - "prosemirror-keymap": "^1.2.2", - "prosemirror-model": "^1.25.0", - "prosemirror-state": "^1.4.3", - "prosemirror-transform": "^1.10.3", - "prosemirror-view": "^1.39.1" + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" } }, "node_modules/prosemirror-trailing-node": { @@ -11165,18 +12330,18 @@ } }, "node_modules/prosemirror-transform": { - "version": "1.10.4", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", - "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", + "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.39.1", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.39.1.tgz", - "integrity": "sha512-GhLxH1xwnqa5VjhJ29LfcQITNDp+f1jzmMPXQfGW9oNrF0lfjPzKvV5y/bjIQkyKpwCX3Fp+GA4dBpMMk8g+ZQ==", + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", + "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", @@ -11185,9 +12350,9 @@ } }, "node_modules/protobufjs": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", - "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -11212,24 +12377,21 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/proxy-target": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/proxy-target/-/proxy-target-3.0.2.tgz", - "integrity": "sha512-FFE1XNwXX/FNC3/P8HiKaJSy/Qk68RitG/QEcLy/bVnTAPlgTAWPZKh0pARLAnpfXQPKyalBhk009NRTgsk8vQ==" - }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true + "integrity": "sha512-FFE1XNwXX/FNC3/P8HiKaJSy/Qk68RitG/QEcLy/bVnTAPlgTAWPZKh0pARLAnpfXQPKyalBhk009NRTgsk8vQ==", + "license": "MIT" }, "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, + "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -11240,6 +12402,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -11248,14 +12411,15 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/pyodide": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.2.tgz", - "integrity": "sha512-2BrZHrALvhYZfIuTGDHOvyiirHNLziHfBiBb1tpBFzLgAvDBb2ACxNPFFROCOzLnqapORmgArDYY8mJmMWH1Eg==", + "version": "0.28.3", + "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.28.3.tgz", + "integrity": "sha512-rtCsyTU55oNGpLzSVuAd55ZvruJDEX8o6keSdWKN9jPeBVSNlynaKFG7eRqkiIgU7i2M6HEgYtm0atCEQX3u4A==", "license": "MPL-2.0", "dependencies": { "ws": "^8.5.0" @@ -11265,12 +12429,13 @@ } }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -11279,28 +12444,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/quansync": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz", - "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11318,78 +12461,90 @@ "type": "consulting", "url": "https://feross.org/support" } - ] - }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", - "dev": true + ], + "license": "MIT" }, "node_modules/quick-temp": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/quick-temp/-/quick-temp-0.1.8.tgz", - "integrity": "sha512-YsmIFfD9j2zaFwJkzI6eMG7y0lQP7YeWzgtFgNl38pGWZBSXJooZbOWwkcRot7Vt0Fg9L23pX0tqWU3VvLDsiA==", + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/quick-temp/-/quick-temp-0.1.9.tgz", + "integrity": "sha512-yI0h7tIhKVObn03kD+Ln9JFi4OljD28lfaOsTdfpTR0xzrhGOod+q66CjGafUqYX2juUfT9oHIGrTBBo22mkRA==", "dev": true, + "license": "MIT", "dependencies": { - "mktemp": "~0.4.0", - "rimraf": "^2.5.4", - "underscore.string": "~3.3.4" + "mktemp": "^2.0.1", + "rimraf": "^5.0.10", + "underscore.string": "~3.3.6" } }, + "node_modules/quick-temp/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/quick-temp/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/quick-temp/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/quick-temp/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/quick-temp/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", "dev": true, + "license": "ISC", "dependencies": { - "glob": "^7.1.3" + "glob": "^10.3.7" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/raf": { @@ -11413,6 +12568,7 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -11423,17 +12579,33 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -11462,13 +12634,15 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/replace-ext": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-2.0.0.tgz", "integrity": "sha512-UszKE5KVK6JvyD92nzMn9cDapSk6w/CaFZ96CnmDMUqH9oowfxF/ZjRITD25H4DnOQClLA4/j7jLGXXLVKxAug==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10" } @@ -11478,28 +12652,27 @@ "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", "dev": true, + "license": "MIT", "dependencies": { "throttleit": "^1.0.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11509,6 +12682,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -11518,6 +12692,7 @@ "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-2.0.0.tgz", "integrity": "sha512-/FopbmmFOQCfsCx77BRFdKOniglTiHumLgwvd6IDPihy1GKkadZbgQJBcTb2lMzSR1pndzd96b1nZrreZ7+9/A==", "dev": true, + "license": "MIT", "dependencies": { "value-or-function": "^4.0.0" }, @@ -11530,6 +12705,7 @@ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, + "license": "MIT", "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -11538,26 +12714,22 @@ "node": ">=8" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, "node_modules/rfdc": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", - "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", - "dev": true + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" }, "node_modules/rgbcolor": { "version": "1.0.1", @@ -11573,7 +12745,9 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -11584,6 +12758,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -11599,7 +12780,9 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -11616,10 +12799,11 @@ } }, "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -11627,6 +12811,23 @@ "node": "*" } }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -11634,11 +12835,12 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", - "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -11648,29 +12850,39 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.22.4", - "@rollup/rollup-android-arm64": "4.22.4", - "@rollup/rollup-darwin-arm64": "4.22.4", - "@rollup/rollup-darwin-x64": "4.22.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", - "@rollup/rollup-linux-arm-musleabihf": "4.22.4", - "@rollup/rollup-linux-arm64-gnu": "4.22.4", - "@rollup/rollup-linux-arm64-musl": "4.22.4", - "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", - "@rollup/rollup-linux-riscv64-gnu": "4.22.4", - "@rollup/rollup-linux-s390x-gnu": "4.22.4", - "@rollup/rollup-linux-x64-gnu": "4.22.4", - "@rollup/rollup-linux-x64-musl": "4.22.4", - "@rollup/rollup-win32-arm64-msvc": "4.22.4", - "@rollup/rollup-win32-ia32-msvc": "4.22.4", - "@rollup/rollup-win32-x64-msvc": "4.22.4", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, "node_modules/rope-sequence": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", - "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==" + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" }, "node_modules/roughjs": { "version": "4.6.6", @@ -11689,6 +12901,7 @@ "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", "dev": true, + "license": "MIT", "engines": { "node": "6.* || >= 7.*" } @@ -11711,10 +12924,35 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -11722,10 +12960,11 @@ "license": "BSD-3-Clause" }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "devOptional": true, + "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" } @@ -11734,6 +12973,8 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", "dependencies": { "mri": "^1.1.0" }, @@ -11742,26 +12983,63 @@ } }, "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } }, "node_modules/sass-embedded": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.81.0.tgz", - "integrity": "sha512-uZQ2Faxb1oWBHpeSSzjxnhClbMb3QadN0ql0ZFNuqWOLUxwaVhrMlMhPq6TDPbbfDUjihuwrMCuy695Bgna5RA==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.98.0.tgz", + "integrity": "sha512-Do7u6iRb6K+lrllcTkB1BXcHwOxcKe3rEfOF/GcCLE2w3WpddakRAosJOHFUR37DpsvimQXEt5abs3NzUjEIqg==", "devOptional": true, "license": "MIT", "dependencies": { - "@bufbuild/protobuf": "^2.0.0", - "buffer-builder": "^0.2.0", + "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", - "immutable": "^5.0.2", + "immutable": "^5.1.5", "rxjs": "^7.4.0", "supports-color": "^8.1.1", "sync-child-process": "^1.0.2", @@ -11774,50 +13052,48 @@ "node": ">=16.0.0" }, "optionalDependencies": { - "sass-embedded-android-arm": "1.81.0", - "sass-embedded-android-arm64": "1.81.0", - "sass-embedded-android-ia32": "1.81.0", - "sass-embedded-android-riscv64": "1.81.0", - "sass-embedded-android-x64": "1.81.0", - "sass-embedded-darwin-arm64": "1.81.0", - "sass-embedded-darwin-x64": "1.81.0", - "sass-embedded-linux-arm": "1.81.0", - "sass-embedded-linux-arm64": "1.81.0", - "sass-embedded-linux-ia32": "1.81.0", - "sass-embedded-linux-musl-arm": "1.81.0", - "sass-embedded-linux-musl-arm64": "1.81.0", - "sass-embedded-linux-musl-ia32": "1.81.0", - "sass-embedded-linux-musl-riscv64": "1.81.0", - "sass-embedded-linux-musl-x64": "1.81.0", - "sass-embedded-linux-riscv64": "1.81.0", - "sass-embedded-linux-x64": "1.81.0", - "sass-embedded-win32-arm64": "1.81.0", - "sass-embedded-win32-ia32": "1.81.0", - "sass-embedded-win32-x64": "1.81.0" - } - }, - "node_modules/sass-embedded-android-arm": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.81.0.tgz", - "integrity": "sha512-NWEmIuaIEsGFNsIRa+5JpIpPJyZ32H15E85CNZqEIhhwWlk9UNw7vlOCmTH8MtabtnACwC/2NG8VyNa3nxKzUQ==", + "sass-embedded-all-unknown": "1.98.0", + "sass-embedded-android-arm": "1.98.0", + "sass-embedded-android-arm64": "1.98.0", + "sass-embedded-android-riscv64": "1.98.0", + "sass-embedded-android-x64": "1.98.0", + "sass-embedded-darwin-arm64": "1.98.0", + "sass-embedded-darwin-x64": "1.98.0", + "sass-embedded-linux-arm": "1.98.0", + "sass-embedded-linux-arm64": "1.98.0", + "sass-embedded-linux-musl-arm": "1.98.0", + "sass-embedded-linux-musl-arm64": "1.98.0", + "sass-embedded-linux-musl-riscv64": "1.98.0", + "sass-embedded-linux-musl-x64": "1.98.0", + "sass-embedded-linux-riscv64": "1.98.0", + "sass-embedded-linux-x64": "1.98.0", + "sass-embedded-unknown-all": "1.98.0", + "sass-embedded-win32-arm64": "1.98.0", + "sass-embedded-win32-x64": "1.98.0" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.98.0.tgz", + "integrity": "sha512-6n4RyK7/1mhdfYvpP3CClS3fGoYqDvRmLClCESS6I7+SAzqjxvGG6u5Fo+cb1nrPNbbilgbM4QKdgcgWHO9NCA==", "cpu": [ - "arm" + "!arm", + "!arm64", + "!riscv64", + "!x64" ], "license": "MIT", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=14.0.0" + "dependencies": { + "sass": "1.98.0" } }, - "node_modules/sass-embedded-android-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.81.0.tgz", - "integrity": "sha512-I36P77/PKAHx6sqOmexO2iEY5kpsmQ1VxcgITZSOxPMQhdB6m4t3bTabfDuWQQmCrqqiNFtLQHeytB65bUqwiw==", + "node_modules/sass-embedded-android-arm": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.98.0.tgz", + "integrity": "sha512-LjGiMhHgu7VL1n7EJxTCre1x14bUsWd9d3dnkS2rku003IWOI/fxc7OXgaKagoVzok1kv09rzO3vFXJR5ZeONQ==", "cpu": [ - "arm64" + "arm" ], "license": "MIT", "optional": true, @@ -11828,12 +13104,12 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded-android-ia32": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.81.0.tgz", - "integrity": "sha512-k8V1usXw30w1GVxvrteG1RzgYJzYQ9PfL2aeOqGdroBN7zYTD9VGJXTGcxA4IeeRxmRd7szVW2mKXXS472fh8g==", + "node_modules/sass-embedded-android-arm64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.98.0.tgz", + "integrity": "sha512-M9Ra98A6vYJHpwhoC/5EuH1eOshQ9ZyNwC8XifUDSbRl/cGeQceT1NReR9wFj3L7s1pIbmes1vMmaY2np0uAKQ==", "cpu": [ - "ia32" + "arm64" ], "license": "MIT", "optional": true, @@ -11845,9 +13121,9 @@ } }, "node_modules/sass-embedded-android-riscv64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.81.0.tgz", - "integrity": "sha512-RXlanyLXEpN/DEehXgLuKPsqT//GYlsGFxKXgRiCc8hIPAueFLQXKJmLWlL3BEtHgmFdbsStIu4aZCcb1hOFlQ==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.98.0.tgz", + "integrity": "sha512-WPe+0NbaJIZE1fq/RfCZANMeIgmy83x4f+SvFOG7LhUthHpZWcOcrPTsCKKmN3xMT3iw+4DXvqTYOCYGRL3hcQ==", "cpu": [ "riscv64" ], @@ -11861,9 +13137,9 @@ } }, "node_modules/sass-embedded-android-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.81.0.tgz", - "integrity": "sha512-RQG0FxGQ1DERNyUDED8+BDVaLIjI+BNg8lVcyqlLZUrWY6NhzjwYEeiN/DNZmMmHtqDucAPNDcsdVUNQqsBy2A==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.98.0.tgz", + "integrity": "sha512-zrD25dT7OHPEgLWuPEByybnIfx4rnCtfge4clBgjZdZ3lF6E7qNLRBtSBmoFflh6Vg0RlEjJo5VlpnTMBM5MQQ==", "cpu": [ "x64" ], @@ -11877,9 +13153,9 @@ } }, "node_modules/sass-embedded-darwin-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.81.0.tgz", - "integrity": "sha512-gLKbsfII9Ppua76N41ODFnKGutla9qv0OGAas8gxe0jYBeAQFi/1iKQYdNtQtKi4mA9n5TQTqz+HHCKszZCoyA==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.98.0.tgz", + "integrity": "sha512-cgr1z9rBnCdMf8K+JabIaYd9Rag2OJi5mjq08XJfbJGMZV/TA6hFJCLGkr5/+ZOn4/geTM5/3aSfQ8z5EIJAOg==", "cpu": [ "arm64" ], @@ -11893,9 +13169,9 @@ } }, "node_modules/sass-embedded-darwin-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.81.0.tgz", - "integrity": "sha512-7uMOlT9hD2KUJCbTN2XcfghDxt/rc50ujjfSjSHjX1SYj7mGplkINUXvVbbvvaV2wt6t9vkGkCo5qNbeBhfwBg==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.98.0.tgz", + "integrity": "sha512-OLBOCs/NPeiMqTdOrMFbVHBQFj19GS3bSVSxIhcCq16ZyhouUkYJEZjxQgzv9SWA2q6Ki8GCqp4k6jMeUY9dcA==", "cpu": [ "x64" ], @@ -11909,9 +13185,9 @@ } }, "node_modules/sass-embedded-linux-arm": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.81.0.tgz", - "integrity": "sha512-REqR9qM4RchCE3cKqzRy9Q4zigIV82SbSpCi/O4O3oK3pg2I1z7vkb3TiJsivusG/li7aqKZGmYOtAXjruGQDA==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.98.0.tgz", + "integrity": "sha512-03baQZCxVyEp8v1NWBRlzGYrmVT/LK7ZrHlF1piscGiGxwfdxoLXVuxsylx3qn/dD/4i/rh7Bzk7reK1br9jvQ==", "cpu": [ "arm" ], @@ -11925,9 +13201,9 @@ } }, "node_modules/sass-embedded-linux-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.81.0.tgz", - "integrity": "sha512-jy4bvhdUmqbyw1jv1f3Uxl+MF8EU/Y/GDx4w6XPJm4Ds+mwH/TwnyAwsxxoBhWfnBnW8q2ADy039DlS5p+9csQ==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.98.0.tgz", + "integrity": "sha512-axOE3t2MTBwCtkUCbrdM++Gj0gC0fdHJPrgzQ+q1WUmY9NoNMGqflBtk5mBZaWUeha2qYO3FawxCB8lctFwCtw==", "cpu": [ "arm64" ], @@ -11940,26 +13216,10 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded-linux-ia32": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.81.0.tgz", - "integrity": "sha512-ga/Jk4q5Bn1aC+iHJteDZuLSKnmBUiS3dEg1fnl/Z7GaHIChceKDJOw0zNaILRXI0qT2E1at9MwzoRaRA5Nn/g==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded-linux-musl-arm": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.81.0.tgz", - "integrity": "sha512-oWVUvQ4d5Kx1Md75YXZl5z1WBjc+uOhfRRqzkJ3nWc8tjszxJN+y/5EOJavhsNI3/2yoTt6eMXRTqDD9b0tWSQ==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.98.0.tgz", + "integrity": "sha512-OBkjTDPYR4hSaueOGIM6FDpl9nt/VZwbSRpbNu9/eEJcxE8G/vynRugW8KRZmCFjPy8j/jkGBvvS+k9iOqKV3g==", "cpu": [ "arm" ], @@ -11973,9 +13233,9 @@ } }, "node_modules/sass-embedded-linux-musl-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.81.0.tgz", - "integrity": "sha512-hpntWf5kjkoxncA1Vh8vhsUOquZ8AROZKx0rQh7ZjSRs4JrYZASz1cfevPKaEM3wIim/nYa6TJqm0VqWsrERlA==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.98.0.tgz", + "integrity": "sha512-LeqNxQA8y4opjhe68CcFvMzCSrBuJqYVFbwElEj9bagHXQHTp9xVPJRn6VcrC+0VLEDq13HVXMv7RslIuU0zmA==", "cpu": [ "arm64" ], @@ -11988,26 +13248,10 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded-linux-musl-ia32": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.81.0.tgz", - "integrity": "sha512-UEXUYkBuqTSwg5JNWiNlfMZ1Jx6SJkaEdx+fsL3Tk099L8cKSoJWH2EPz4ZJjNbyIMymrSdVfymheTeZ8u24xA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/sass-embedded-linux-musl-riscv64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.81.0.tgz", - "integrity": "sha512-1D7OznytbIhx2XDHWi1nuQ8d/uCVR7FGGzELgaU//T8A9DapVTUgPKvB70AF1k4GzChR9IXU/WvFZs2hDTbaJg==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.98.0.tgz", + "integrity": "sha512-7w6hSuOHKt8FZsmjRb3iGSxEzM87fO9+M8nt5JIQYMhHTj5C+JY/vcske0v715HCVj5e1xyTnbGXf8FcASeAIw==", "cpu": [ "riscv64" ], @@ -12021,9 +13265,9 @@ } }, "node_modules/sass-embedded-linux-musl-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.81.0.tgz", - "integrity": "sha512-ia6VCTeVDQtBSMktXRFza1AZCt8/6aUoujot6Ugf4KmdytQqPJIHxkHaGftm5xwi9WdrMGYS7zgolToPijR11A==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.98.0.tgz", + "integrity": "sha512-QikNyDEJOVqPmxyCFkci8ZdCwEssdItfjQFJB+D+Uy5HFqcS5Lv3d3GxWNX/h1dSb23RPyQdQc267ok5SbEyJw==", "cpu": [ "x64" ], @@ -12037,9 +13281,9 @@ } }, "node_modules/sass-embedded-linux-riscv64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.81.0.tgz", - "integrity": "sha512-KbxSsqu4tT1XbhZfJV/5NfW0VtJIGlD58RjqJqJBi8Rnjrx29/upBsuwoDWtsPV/LhoGwwU1XkSa9Q1ifCz4fQ==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.98.0.tgz", + "integrity": "sha512-E7fNytc/v4xFBQKzgzBddV/jretA4ULAPO6XmtBiQu4zZBdBozuSxsQLe2+XXeb0X4S2GIl72V7IPABdqke/vA==", "cpu": [ "riscv64" ], @@ -12053,9 +13297,9 @@ } }, "node_modules/sass-embedded-linux-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.81.0.tgz", - "integrity": "sha512-AMDeVY2T9WAnSFkuQcsOn5c29GRs/TuqnCiblKeXfxCSKym5uKdBl/N7GnTV6OjzoxiJBbkYKdVIaS5By7Gj4g==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.98.0.tgz", + "integrity": "sha512-VsvP0t/uw00mMNPv3vwyYKUrFbqzxQHnRMO+bHdAMjvLw4NFf6mscpym9Bzf+NXwi1ZNKnB6DtXjmcpcvqFqYg==", "cpu": [ "x64" ], @@ -12068,28 +13312,28 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded-win32-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.81.0.tgz", - "integrity": "sha512-YOmBRYnygwWUmCoH14QbMRHjcvCJufeJBAp0m61tOJXIQh64ziwV4mjdqjS/Rx3zhTT4T+nulDUw4d3kLiMncA==", - "cpu": [ - "arm64" - ], + "node_modules/sass-embedded-unknown-all": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.98.0.tgz", + "integrity": "sha512-C4MMzcAo3oEDQnW7L8SBgB9F2Fq5qHPnaYTZRMOH3Mp/7kM4OooBInXpCiiFjLnjY95hzP4KyctVx0uYR6MYlQ==", "license": "MIT", "optional": true, "os": [ - "win32" + "!android", + "!darwin", + "!linux", + "!win32" ], - "engines": { - "node": ">=14.0.0" + "dependencies": { + "sass": "1.98.0" } }, - "node_modules/sass-embedded-win32-ia32": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.81.0.tgz", - "integrity": "sha512-HFfr/C+uLJGGTENdnssuNTmXI/xnIasUuEHEKqI+2J0FHCWT5cpz3PGAOHymPyJcZVYGUG/7gIxIx/d7t0LFYw==", + "node_modules/sass-embedded-win32-arm64": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.98.0.tgz", + "integrity": "sha512-nP/10xbAiPbhQkMr3zQfXE4TuOxPzWRQe1Hgbi90jv2R4TbzbqQTuZVOaJf7KOAN4L2Bo6XCTRjK5XkVnwZuwQ==", "cpu": [ - "ia32" + "arm64" ], "license": "MIT", "optional": true, @@ -12101,9 +13345,9 @@ } }, "node_modules/sass-embedded-win32-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.81.0.tgz", - "integrity": "sha512-wxj52jDcIAwWcXb7ShZ7vQYKcVUkJ+04YM9l46jDY+qwHzliGuorAUyujLyKTE9heGD3gShJ3wPPC1lXzq6v9A==", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.98.0.tgz", + "integrity": "sha512-/lbrVsfbcbdZQ5SJCWcV0NVPd6YRs+FtAnfedp4WbCkO/ZO7Zt/58MvI4X2BVpRY/Nt5ZBo1/7v2gYcQ+J4svQ==", "cpu": [ "x64" ], @@ -12116,54 +13360,56 @@ "node": ">=14.0.0" } }, - "node_modules/sass-embedded/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "devOptional": true, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "type-fest": "^0.13.1" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/set-cookie-parser": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "license": "MIT" }, "node_modules/setimmediate": { "version": "1.0.5", @@ -12172,14 +13418,15 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -12188,31 +13435,38 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -12224,22 +13478,24 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/shiki": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.1.tgz", - "integrity": "sha512-EkAEhDTN5WhpoQFXFw79OHIrSAfHhlImeCdSyg4u4XvrpxKEmdo/9x/HWSowujAnUrFsGOwWiE58a6GVentMnQ==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.0.1", - "@shikijs/engine-javascript": "4.0.1", - "@shikijs/engine-oniguruma": "4.0.1", - "@shikijs/langs": "4.0.1", - "@shikijs/themes": "4.0.1", - "@shikijs/types": "4.0.1", + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" }, @@ -12248,15 +13504,17 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -12265,35 +13523,80 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, "engines": { - "node": ">=14" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", "dependencies": { - "is-arrayish": "^0.3.1" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", - "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -12309,6 +13612,7 @@ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", @@ -12319,13 +13623,14 @@ } }, "node_modules/socket.io-client": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", - "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.5.2", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" }, "engines": { @@ -12333,22 +13638,24 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, "node_modules/sort-keys": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-5.0.0.tgz", - "integrity": "sha512-Pdz01AvCAottHTPQGzndktFNdbRA75BgOfeT1hH+AMnJFv8lynkPi42rfeEhpx1saTEI3YNMWxfqu0sFD1G8pw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-5.1.0.tgz", + "integrity": "sha512-aSbHV0DaBcr7u0PVHXzM6NbZNAtrr9sF6+Qfs9UUVG7Ll3jQ6hHi8F/xqIIcn2rvIVbr0v/2zyjSdwSV47AgLQ==", "dev": true, + "license": "MIT", "dependencies": { "is-plain-obj": "^4.0.0" }, @@ -12360,15 +13667,16 @@ } }, "node_modules/sortablejs": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", - "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", "license": "MIT" }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -12387,7 +13695,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true + "license": "BSD-3-Clause" }, "node_modules/sql.js": { "version": "1.14.1", @@ -12412,6 +13720,7 @@ "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "dev": true, + "license": "MIT", "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -12436,7 +13745,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/stackblur-canvas": { "version": "2.7.0", @@ -12449,60 +13759,68 @@ } }, "node_modules/std-env": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", - "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", - "dev": true + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" }, "node_modules/sticky-module": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/sticky-module/-/sticky-module-0.1.1.tgz", - "integrity": "sha512-IuYgnyIMUx/m6rtu14l/LR2MaqOLtpXcWkxPmtPsiScRHEo+S4Tojk+DWFHOncSdFX/OsoLOM4+T92yOmI1AMw==" + "integrity": "sha512-IuYgnyIMUx/m6rtu14l/LR2MaqOLtpXcWkxPmtPsiScRHEo+S4Tojk+DWFHOncSdFX/OsoLOM4+T92yOmI1AMw==", + "license": "ISC" }, "node_modules/stream-composer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", "integrity": "sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==", "dev": true, + "license": "MIT", "dependencies": { "streamx": "^2.13.2" } }, "node_modules/streamx": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.16.1.tgz", - "integrity": "sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "dev": true, + "license": "MIT", "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" } }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" } }, - "node_modules/string-width": { + "node_modules/string_decoder/node_modules/safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/string-width-cjs": { @@ -12510,6 +13828,8 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -12519,36 +13839,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -12567,6 +13857,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -12579,6 +13871,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -12591,6 +13885,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -12600,6 +13895,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -12608,21 +13904,32 @@ } }, "node_modules/strip-literal": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.0.tgz", - "integrity": "sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", "dev": true, + "license": "MIT", "dependencies": { - "js-tokens": "^9.0.0" + "js-tokens": "^9.0.1" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, "node_modules/style-mod": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", - "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } }, "node_modules/stylis": { "version": "4.3.6", @@ -12631,21 +13938,26 @@ "license": "MIT" }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "devOptional": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -12654,21 +13966,23 @@ } }, "node_modules/svelte": { - "version": "5.42.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.2.tgz", - "integrity": "sha512-iSry5jsBHispVczyt9UrBX/1qu3HQ/UyKPAIjqlvlu3o/eUvc+kpyMyRS2O4HLLx4MvLurLGIUOyyP11pyD59g==", + "version": "5.53.12", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz", + "integrity": "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", - "aria-query": "^5.3.1", + "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.6.4", "esm-env": "^1.2.1", - "esrap": "^2.1.0", + "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -12679,9 +13993,9 @@ } }, "node_modules/svelte-check": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz", - "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", "dev": true, "license": "MIT", "dependencies": { @@ -12702,61 +14016,14 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-check/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/svelte-check/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/svelte-check/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/svelte-confetti": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/svelte-confetti/-/svelte-confetti-1.3.2.tgz", - "integrity": "sha512-R+JwFTC7hIgWVA/OuXrkj384B7CMoceb0t9VacyW6dORTQg0pWojVBB8Bo3tM30cLEQE48Fekzqgx+XSzHESMA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/svelte-confetti/-/svelte-confetti-2.3.2.tgz", + "integrity": "sha512-cfIwoGqMPYWRYDUz2g7mG1uHYWy7VBepelQdzCC3j/M42UrAqaBYmIi9xaoQfow4fbINHO9WuARnTyK2bjjGQg==", "dev": true, + "license": "MIT", "peerDependencies": { - "svelte": "^4.0.0" + "svelte": ">=5.0.0" } }, "node_modules/svelte-eslint-parser": { @@ -12791,15 +14058,30 @@ "version": "0.3.28", "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-0.3.28.tgz", "integrity": "sha512-K3AmlySeFifF/cKgsYNv5uXqMVNln0NBAacOYgmkQStLa/UoU0LhfAACU6Gr+YYC8bOCHdVmFNoKuDbMEsppJg==", + "license": "MIT", "peerDependencies": { "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" } }, - "node_modules/svelte/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } }, "node_modules/svelte/node_modules/is-reference": { "version": "3.0.3", @@ -12824,7 +14106,8 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", "integrity": "sha512-0K91MEXFpBUaywiwSSkmKjnGcasG/rVBXFLJz5DrgGabpYD6N+3yZrfD6uUIfpuTu65DZLHi7N8CizHc07BPZA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/sync-child-process": { "version": "1.0.2", @@ -12840,9 +14123,9 @@ } }, "node_modules/sync-message-port": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", - "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.2.0.tgz", + "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", "devOptional": true, "license": "MIT", "engines": { @@ -12850,68 +14133,68 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.0.tgz", - "integrity": "sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "license": "ISC", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { "node": ">=18" } }, - "node_modules/tar/node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "dev": true, + "license": "MIT", "dependencies": { "streamx": "^2.12.5" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -12925,13 +14208,15 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/throttleit": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -12940,35 +14225,59 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", "dev": true, + "license": "MIT", "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", - "dev": true + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "license": "MIT" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } }, "node_modules/tinypool": { "version": "0.8.4", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -12987,15 +14296,37 @@ "version": "6.3.7", "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", "dependencies": { "@popperjs/core": "^2.9.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.14" } @@ -13003,12 +14334,14 @@ "node_modules/to-json-callback": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/to-json-callback/-/to-json-callback-0.1.1.tgz", - "integrity": "sha512-BzOeinTT3NjE+FJ2iCvWB8HvyuyBzoH3WlSnJ+AYVC4tlePyZWSYdkQIFOARWiq0t35/XhmI0uQsFiUsRksRqg==" + "integrity": "sha512-BzOeinTT3NjE+FJ2iCvWB8HvyuyBzoH3WlSnJ+AYVC4tlePyZWSYdkQIFOARWiq0t35/XhmI0uQsFiUsRksRqg==", + "license": "ISC" }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -13021,6 +14354,7 @@ "resolved": "https://registry.npmjs.org/to-through/-/to-through-3.0.0.tgz", "integrity": "sha512-y8MN937s/HVhEoBU1SxfHC+wxCHkV1a9gW8eAdTadYh/bGyesZIVcbjI+mSpFbSVwQici/XjBjuUyri1dnXwBw==", "dev": true, + "license": "MIT", "dependencies": { "streamx": "^2.12.5" }, @@ -13058,27 +14392,26 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" + "node": ">=16" } }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, - "engines": { - "node": ">= 4.0.0" + "license": "MIT", + "bin": { + "tree-kill": "cli.js" } }, "node_modules/trim-lines": { @@ -13092,9 +14425,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -13108,6 +14441,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "license": "MIT", "engines": { "node": ">=6.10" } @@ -13123,6 +14457,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" }, @@ -13131,9 +14466,9 @@ } }, "node_modules/turndown": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", - "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz", + "integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==", "license": "MIT", "dependencies": { "@mixmark-io/domino": "^2.2.0" @@ -13149,13 +14484,15 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "dev": true + "dev": true, + "license": "Unlicense" }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -13166,7 +14503,8 @@ "node_modules/type-checked-collections": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/type-checked-collections/-/type-checked-collections-0.1.7.tgz", - "integrity": "sha512-fLIydlJy7IG9XL4wjRwEcKhxx/ekLXiWiMvcGo01cOMF+TN+5ZqajM1mRNRz2bNNi1bzou2yofhjZEQi7kgl9A==" + "integrity": "sha512-fLIydlJy7IG9XL4wjRwEcKhxx/ekLXiWiMvcGo01cOMF+TN+5ZqajM1mRNRz2bNNi1bzou2yofhjZEQi7kgl9A==", + "license": "ISC" }, "node_modules/type-detect": { "version": "4.1.0", @@ -13183,6 +14521,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -13191,10 +14530,11 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13206,18 +14546,19 @@ "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, "node_modules/underscore.string": { @@ -13225,6 +14566,7 @@ "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.6.tgz", "integrity": "sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==", "dev": true, + "license": "MIT", "dependencies": { "sprintf-js": "^1.1.1", "util-deprecate": "^1.0.2" @@ -13234,18 +14576,19 @@ } }, "node_modules/undici": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.11.0.tgz", - "integrity": "sha512-heTSIac3iLhsmZhUCjyS3JQEkZELateufzZuBaVM5RHXdSBMb1LPMQf5x+FH7qjsZYDP0ttAc3nnVpUB+wYbOg==", + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz", + "integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" }, "node_modules/unist-util-is": { "version": "6.0.1", @@ -13319,6 +14662,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", "engines": { "node": ">= 10.0.0" } @@ -13328,6 +14672,7 @@ "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -13337,24 +14682,16 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" + "punycode": "^2.1.0" } }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" }, "node_modules/utrie": { "version": "1.0.2", @@ -13373,6 +14710,7 @@ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } @@ -13382,6 +14720,7 @@ "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz", "integrity": "sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10.13.0" } @@ -13488,12 +14827,6 @@ "vega-util": "^2.1.0" } }, - "node_modules/vega-expression/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, "node_modules/vega-force": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/vega-force/-/vega-force-5.1.0.tgz", @@ -13519,9 +14852,9 @@ } }, "node_modules/vega-functions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-6.1.0.tgz", - "integrity": "sha512-yooEbWt0FWMBNoohwLsl25lEh08WsWabTXbbS+q0IXZzWSpX4Cyi45+q7IFyy/2L4oaIfGIIV14dgn3srQQcGA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-6.1.1.tgz", + "integrity": "sha512-Due6jP0y0FfsGMTrHnzUGnEwXPu7VwE+9relfo+LjL/tRPYnnKqwWvzt7n9JkeBuZqjkgYjMzm/WucNn6Hkw5A==", "license": "BSD-3-Clause", "dependencies": { "d3-array": "^3.2.4", @@ -13577,9 +14910,9 @@ } }, "node_modules/vega-lite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.1.tgz", - "integrity": "sha512-KO3ybHNouRK4A0al/+2fN9UqgTEfxrd/ntGLY933Hg5UOYotDVQdshR3zn7OfXwQ7uj0W96Vfa5R+QxO8am3IQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.2.tgz", + "integrity": "sha512-Mv2PaRIpijz256LM0NdOJd9Md8cqyrXina54xW6Qp865YfY502zlXGUst+W/XznVwISGfatt0yLZuDqCUbBDuw==", "license": "BSD-3-Clause", "dependencies": { "json-stringify-pretty-compact": "~4.0.0", @@ -13596,7 +14929,7 @@ "vl2vg": "bin/vl2vg" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" @@ -13692,9 +15025,9 @@ } }, "node_modules/vega-selections": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-6.1.0.tgz", - "integrity": "sha512-WaHM7D7ghHceEfMsgFeaZnDToWL0mgCFtStVOobNh/OJLh0CL7yNKeKQBqRXJv2Lx74dPNf6nj08+52ytWfW7g==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-6.1.2.tgz", + "integrity": "sha512-xJ+V4qdd46nk2RBdwIRrQm2iSTMHdlu/omhLz1pqRL3jZDrkqNBXimrisci2kIKpH2WBpA1YVagwuZEKBmF2Qw==", "license": "BSD-3-Clause", "dependencies": { "d3-array": "3.2.4", @@ -13812,6 +15145,7 @@ "engines": [ "node >=0.6.0" ], + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -13822,7 +15156,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/vfile": { "version": "6.0.3", @@ -13853,13 +15188,13 @@ } }, "node_modules/vinyl": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.0.tgz", - "integrity": "sha512-rC2VRfAVVCGEgjnxHUnpIVh3AGuk62rP3tqVrn+yab0YH7UULisC085+NYH+mnqf3Wx4SpSi1RQMwudL89N03g==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-3.0.1.tgz", + "integrity": "sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==", "dev": true, + "license": "MIT", "dependencies": { "clone": "^2.1.2", - "clone-stats": "^1.0.0", "remove-trailing-separator": "^1.1.0", "replace-ext": "^2.0.0", "teex": "^1.0.1" @@ -13873,6 +15208,7 @@ "resolved": "https://registry.npmjs.org/vinyl-contents/-/vinyl-contents-2.0.0.tgz", "integrity": "sha512-cHq6NnGyi2pZ7xwdHSW1v4Jfnho4TEGtxZHw01cmnc8+i7jgR6bRnED/LbrKan/Q7CvVLbnvA5OepnhbpjBZ5Q==", "dev": true, + "license": "MIT", "dependencies": { "bl": "^5.0.0", "vinyl": "^3.0.0" @@ -13882,13 +15218,14 @@ } }, "node_modules/vinyl-fs": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.0.tgz", - "integrity": "sha512-7GbgBnYfaquMk3Qu9g22x000vbYkOex32930rBnc3qByw6HfMEAoELjCjoJv4HuEQxHAurT+nvMHm6MnJllFLw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-4.0.2.tgz", + "integrity": "sha512-XRFwBLLTl8lRAOYiBqxY279wY46tVxLaRhSwo3GzKEuLz1giffsOquWWboD/haGf5lx+JyTigCFfe7DWHoARIA==", "dev": true, + "license": "MIT", "dependencies": { "fs-mkdirp-stream": "^2.0.1", - "glob-stream": "^8.0.0", + "glob-stream": "^8.0.3", "graceful-fs": "^4.2.11", "iconv-lite": "^0.6.3", "is-valid-glob": "^1.0.0", @@ -13899,7 +15236,7 @@ "streamx": "^2.14.0", "to-through": "^3.0.0", "value-or-function": "^4.0.0", - "vinyl": "^3.0.0", + "vinyl": "^3.0.1", "vinyl-sourcemap": "^2.0.0" }, "engines": { @@ -13911,6 +15248,7 @@ "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-2.0.0.tgz", "integrity": "sha512-BAEvWxbBUXvlNoFQVFVHpybBbjW1r03WhohJzJDSfgrrK5xVYIDTan6xN14DlyImShgDRv2gl9qhM6irVMsV0Q==", "dev": true, + "license": "MIT", "dependencies": { "convert-source-map": "^2.0.0", "graceful-fs": "^4.2.10", @@ -14005,15 +15343,23 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite-plugin-static-copy": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.2.0.tgz", - "integrity": "sha512-ytMrKdR9iWEYHbUxs6x53m+MRl4SJsOSoMu1U1+Pfg0DjPeMlsRVx3RR5jvoonineDquIue83Oq69JvNsFSU5w==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-2.3.2.tgz", + "integrity": "sha512-iwrrf+JupY4b9stBttRWzGHzZbeMjAHBhkrn67MNACXJVjEMRpCI10Q3AkxdBkl45IHaTfw/CNVevzQhP7yTwg==", "license": "MIT", "dependencies": { "chokidar": "^3.5.3", "fast-glob": "^3.2.11", "fs-extra": "^11.1.0", + "p-map": "^7.0.3", "picocolors": "^1.0.0" }, "engines": { @@ -14023,6 +15369,92 @@ "vite": "^5.0.0 || ^6.0.0" } }, + "node_modules/vite-plugin-static-copy/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -14030,6 +15462,7 @@ "cpu": [ "ppc64" ], + "license": "MIT", "optional": true, "os": [ "aix" @@ -14045,6 +15478,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -14060,6 +15494,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -14075,6 +15510,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "android" @@ -14090,6 +15526,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -14105,6 +15542,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "darwin" @@ -14120,6 +15558,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -14135,6 +15574,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -14150,6 +15590,7 @@ "cpu": [ "arm" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14165,6 +15606,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14180,6 +15622,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14195,6 +15638,7 @@ "cpu": [ "loong64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14210,6 +15654,7 @@ "cpu": [ "mips64el" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14225,6 +15670,7 @@ "cpu": [ "ppc64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14240,6 +15686,7 @@ "cpu": [ "riscv64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14255,6 +15702,7 @@ "cpu": [ "s390x" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14270,6 +15718,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -14285,6 +15734,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -14300,6 +15750,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -14315,6 +15766,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "sunos" @@ -14330,6 +15782,7 @@ "cpu": [ "arm64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -14345,6 +15798,7 @@ "cpu": [ "ia32" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -14360,6 +15814,7 @@ "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "win32" @@ -14373,6 +15828,7 @@ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -14406,9 +15862,9 @@ } }, "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", "license": "MIT", "workspaces": [ "tests/deps/*", @@ -14416,7 +15872,7 @@ "tests/projects/workspace/packages/*" ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" }, "peerDependenciesMeta": { "vite": { @@ -14495,6 +15951,7 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", @@ -14518,6 +15975,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "dev": true, + "license": "MIT", "engines": { "node": ">=16" }, @@ -14530,6 +15988,7 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=16.17.0" } @@ -14539,6 +15998,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -14551,6 +16011,7 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -14563,6 +16024,7 @@ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^4.0.0" }, @@ -14578,6 +16040,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^4.0.0" }, @@ -14593,6 +16056,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -14600,11 +16064,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/vitest/node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -14656,21 +16141,23 @@ "license": "MIT" }, "node_modules/vscode-uri": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", - "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" }, "node_modules/walk-sync": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", "integrity": "sha512-IC8sL7aB4/ZgFcGI2T1LczZeFWZ06b3zoHH7jBPyHxOtIIz1jppWHjjEXkOFvFojBVAK9pV7g47xOZ4LW3QLfg==", "dev": true, + "license": "MIT", "dependencies": { "@types/minimatch": "^3.0.3", "ensure-posix-path": "^1.1.0", @@ -14681,6 +16168,13 @@ "node": "8.* || >= 10.*" } }, + "node_modules/walk-sync/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/walk-sync/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -14693,10 +16187,11 @@ } }, "node_modules/walk-sync/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -14708,6 +16203,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, "license": "MIT", "dependencies": { @@ -14730,12 +16226,15 @@ "node_modules/wheel": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz", - "integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==" + "integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==", + "license": "MIT" }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -14747,10 +16246,11 @@ } }, "node_modules/why-is-node-running": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", - "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, + "license": "MIT", "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -14780,17 +16280,29 @@ "node": ">=0.8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -14801,6 +16313,8 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -14813,69 +16327,17 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" }, "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -14923,9 +16385,9 @@ } }, "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", "engines": { "node": ">=0.4.0" } @@ -14935,6 +16397,7 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4" } @@ -15002,15 +16465,18 @@ } }, "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { @@ -15052,9 +16518,9 @@ } }, "node_modules/yargs/node_modules/emoji-regex": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", - "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/yargs/node_modules/string-width": { @@ -15075,12 +16541,12 @@ } }, "node_modules/yargs/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -15094,15 +16560,16 @@ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "dev": true, + "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "node_modules/yjs": { - "version": "13.6.27", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", - "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", "license": "MIT", "dependencies": { "lib0": "^0.2.99" @@ -15121,6 +16588,7 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 1faacdacdf..e0d8bf97f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.10.2", + "version": "0.8.11.1", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -15,7 +15,7 @@ "lint:types": "npm run check", "lint:backend": "pylint backend/", "format": "prettier --plugin-search-dir --write \"**/*.{js,ts,svelte,css,md,html,json}\"", - "format:backend": "black . --exclude \".venv/|/venv/\"", + "format:backend": "ruff format . --exclude .venv --exclude venv", "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\"", "cy:open": "cypress open", "test:frontend": "vitest --passWithNoTests", @@ -41,9 +41,9 @@ "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", "sass-embedded": "^1.81.0", - "svelte": "^5.0.0", + "svelte": "^5.53.10", "svelte-check": "^4.0.0", - "svelte-confetti": "^1.3.2", + "svelte-confetti": "^2.3.2", "tailwindcss": "^4.0.0", "tslib": "^2.4.1", "typescript": "^5.5.4", @@ -65,12 +65,12 @@ "@sveltejs/adapter-node": "^2.0.0", "@sveltejs/svelte-virtual-list": "^3.0.1", "@tiptap/core": "^3.0.7", - "@tiptap/extension-bubble-menu": "^2.26.1", + "@tiptap/extension-bubble-menu": "^3.0.7", "@tiptap/extension-code": "^3.0.7", "@tiptap/extension-code-block-lowlight": "^3.0.7", "@tiptap/extension-drag-handle": "^3.4.5", "@tiptap/extension-file-handler": "^3.0.7", - "@tiptap/extension-floating-menu": "^2.26.1", + "@tiptap/extension-floating-menu": "^3.0.7", "@tiptap/extension-highlight": "^3.3.0", "@tiptap/extension-image": "^3.0.7", "@tiptap/extension-link": "^3.0.7", @@ -89,7 +89,7 @@ "@xyflow/svelte": "^0.1.19", "alpinejs": "^3.15.0", "async": "^3.2.5", - "bits-ui": "^0.21.15", + "bits-ui": "^2.0.0", "chart.js": "^4.5.0", "codemirror": "^6.0.1", "codemirror-lang-elixir": "^4.0.0", diff --git a/pyproject.toml b/pyproject.toml index 2ec9d033b9..7883a459e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "python-socketio==5.16.1", "python-jose==3.5.0", - "cryptography", + "cryptography==46.0.5", "bcrypt==5.0.0", "argon2-cffi==25.1.0", "PyJWT[crypto]==2.11.0", @@ -22,9 +22,9 @@ dependencies = [ "requests==2.32.5", "aiohttp==3.13.2", # do not update to 3.13.3 - broken - "async-timeout", - "aiocache", - "aiofiles", + "async-timeout==5.0.1", + "aiocache==0.12.3", + "aiofiles==25.1.0", "starlette-compress==1.7.0", "Brotli==1.1.0", "httpx[socks,http2,zstd,cli,brotli]==0.28.1", @@ -37,7 +37,7 @@ dependencies = [ "peewee-migrate==1.14.3", "pycrdt==0.12.47", - "redis", + "redis==7.4.0", "pytz==2026.1.post1", "APScheduler==3.11.2", @@ -46,11 +46,11 @@ dependencies = [ "loguru==0.7.3", "asgiref==3.11.1", - "tiktoken", + "tiktoken==0.12.0", "mcp==1.26.0", - "openai", - "anthropic", + "openai==2.29.0", + "anthropic==0.86.0", "google-genai==1.66.0", "langchain==1.2.10", @@ -66,7 +66,7 @@ dependencies = [ "transformers==5.3.0", "sentence-transformers==5.2.3", - "accelerate", + "accelerate==1.13.0", "pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897 "einops==0.8.2", @@ -77,7 +77,6 @@ dependencies = [ "pymdown-extensions==10.21", "docx2txt==0.9", "python-pptx==1.0.2", - "unstructured==0.18.31", "msoffcrypto-tool==6.0.0", "nltk==3.9.3", "Markdown==3.10.2", @@ -87,8 +86,8 @@ dependencies = [ "pyxlsb==1.0.10", "xlrd==2.0.2", "validators==0.35.0", - "psutil", - "sentencepiece", + "psutil==7.2.2", + "sentencepiece==0.2.1", "soundfile==0.13.1", "azure-ai-documentintelligence==1.0.2", @@ -104,12 +103,12 @@ dependencies = [ "youtube-transcript-api==1.2.4", "pytube==15.0.0", - "pydub", - "ddgs==9.11.2", + "pydub==0.25.1", + "ddgs==9.11.3", - "google-api-python-client", - "google-auth-httplib2", - "google-auth-oauthlib", + "google-api-python-client==2.193.0", + "google-auth-httplib2==0.3.0", + "google-auth-oauthlib==1.3.0", "googleapis-common-protos==1.72.0", "google-cloud-storage==3.9.0", @@ -140,12 +139,14 @@ postgres = [ mariadb = [ "mariadb==1.1.14", ] +unstructured = [ + "unstructured==0.18.31", +] all = [ - "pymongo", + "pymongo==4.16.0", "psycopg2-binary==2.9.11", "pgvector==0.4.2", - "mariadb==1.1.14", "moto[s3]>=5.0.26", "gcp-storage-emulator>=2024.8.3", "docker~=7.1.0", @@ -164,6 +165,7 @@ all = [ "firecrawl-py==4.18.0", "azure-search-documents==11.6.0", + "unstructured==0.18.31", ] [project.scripts] @@ -211,4 +213,35 @@ ignore-words-list = 'ans' [dependency-groups] dev = [ "pytest-asyncio>=1.0.0", + "ruff>=0.15.5", +] + +[tool.black] +line-length = 120 +skip-string-normalization = true + +[tool.ruff] +line-length = 120 + +[tool.ruff.format] +quote-style = "single" +docstring-code-format = false + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade + "C90", # mccabe + "Q", # flake8-quotes + "ICN", # flake8-import-conventions ] + +# Plugin configs: +flake8-import-conventions.banned-from = [ "ast", "datetime" ] +flake8-import-conventions.aliases = { datetime = "dt" } +flake8-quotes.inline-quotes = "single" +mccabe.max-complexity = 10 +pydocstyle.convention = "google" diff --git a/scripts/generate-sbom.sh b/scripts/generate-sbom.sh new file mode 100755 index 0000000000..dd91087eba --- /dev/null +++ b/scripts/generate-sbom.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# +# generate-sbom.sh โ€” Generate a clean CycloneDX SBOM using Syft +# +# Produces a single SBOM from resolved manifests only โ€” no directory scanning, +# no venv pollution, no local state. Works identically locally and in CI. +# +# How it works: +# 1. Python: uv pip compile resolves all transitive deps from requirements.txt +# 2. JavaScript: package-lock.json already contains the full resolved tree +# 3. Syft scans these resolved files, not the filesystem +# +# Usage: +# ./scripts/generate-sbom.sh # generate sbom.cdx.json from manifests +# ./scripts/generate-sbom.sh docker # generate from Docker image (best license coverage) +# ./scripts/generate-sbom.sh docker IMG # generate from a specific image +# ./scripts/generate-sbom.sh validate # validate existing SBOM +# +# Requirements: +# - syft (brew install syft) +# - uv (brew install uv) +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +DIM='\033[2m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${BOLD}${GREEN}โ–ธ${RESET} $1"; } +warn() { echo -e "${BOLD}${RED}โ–ธ${RESET} $1"; } +dim() { echo -e "${DIM} $1${RESET}"; } + +OUTPUT="$ROOT_DIR/sbom.cdx.json" + +check_deps() { + local missing=() + command -v syft &>/dev/null || missing+=("syft") + command -v uv &>/dev/null || missing+=("uv") + if [[ ${#missing[@]} -gt 0 ]]; then + warn "Missing: ${missing[*]}. Install with: brew install ${missing[*]}" + exit 1 + fi + dim "Using $(syft --version), $(uv --version)" +} + +generate() { + info "Generating SBOM from resolved manifests..." + check_deps + + local VERSION + VERSION="$(python3 -c "import json; print(json.load(open('$ROOT_DIR/package.json'))['version'])")" + + local WORK_DIR + WORK_DIR="$(mktemp -d)" + trap 'rm -rf "$WORK_DIR"' RETURN + + # --- Python: resolve all transitive deps without installing --- + dim "Resolving Python transitive deps (uv pip compile)..." + uv pip compile "$ROOT_DIR/backend/requirements.txt" \ + --python-version 3.11 \ + --quiet \ + > "$WORK_DIR/requirements-resolved.txt" 2>/dev/null + + # --- JavaScript: package-lock.json is already fully resolved --- + if [[ -f "$ROOT_DIR/package-lock.json" ]]; then + cp "$ROOT_DIR/package-lock.json" "$WORK_DIR/package-lock.json" + # Syft needs package.json alongside the lockfile + cp "$ROOT_DIR/package.json" "$WORK_DIR/package.json" + else + warn "package-lock.json not found โ€” JS deps will be skipped" + fi + + # --- Scan only the resolved files --- + dim "Scanning resolved manifests with Syft..." + syft scan "dir:$WORK_DIR" \ + --output "cyclonedx-json=$OUTPUT" \ + --source-name open-webui \ + --source-version "$VERSION" \ + --quiet + + # Print summary + python3 -c " +import json +with open('$OUTPUT') as f: + data = json.load(f) +comps = data.get('components', []) +py = [c for c in comps if 'pypi' in c.get('purl', '')] +js = [c for c in comps if 'npm' in c.get('purl', '')] +with_lic = sum(1 for c in comps if c.get('licenses')) +print(f' {len(comps)} total ({len(py)} Python, {len(js)} JavaScript)') +print(f' {with_lic}/{len(comps)} with license info') +print(f' Serial: {data.get(\"serialNumber\", \"none\")}') +print(f' Timestamp: {data.get(\"metadata\", {}).get(\"timestamp\", \"none\")}') +" + + info "SBOM written โ†’ sbom.cdx.json" +} + +generate_docker() { + local IMAGE="${1:-ghcr.io/open-webui/open-webui:latest}" + info "Generating SBOM from Docker image: $IMAGE" + + if ! command -v syft &>/dev/null; then + warn "syft is not installed. Install with: brew install syft" + exit 1 + fi + + dim "Pulling and scanning image..." + syft scan "docker:$IMAGE" \ + --output "cyclonedx-json=$OUTPUT" \ + --quiet + + python3 -c " +import json +with open('$OUTPUT') as f: + data = json.load(f) +comps = data.get('components', []) +with_lic = sum(1 for c in comps if c.get('licenses')) +print(f' {len(comps)} total components') +print(f' {with_lic}/{len(comps)} with license info ({round(with_lic/max(len(comps),1)*100)}%)') +" + + info "SBOM written โ†’ sbom.cdx.json" +} + +validate() { + info "Validating SBOM..." + + python3 -c " +import json, sys + +try: + with open('$OUTPUT') as f: + data = json.load(f) +except FileNotFoundError: + print(' โœ— sbom.cdx.json not found โ€” run ./scripts/generate-sbom.sh first') + sys.exit(1) + +issues = [] +if data.get('bomFormat') != 'CycloneDX': + issues.append('Not CycloneDX format') +if not data.get('specVersion'): + issues.append('Missing specVersion') +if not data.get('serialNumber'): + issues.append('Missing serial number') + +components = data.get('components', []) + +# Check for phantom local packages +phantoms = [] +for c in components: + for ref in c.get('externalReferences', []): + url = ref.get('url', '') + if 'file://' in url and '/Users/' in url: + phantoms.append(c['name']) +if phantoms: + issues.append(f'Phantom local packages: {phantoms}') + +with_lic = sum(1 for c in components if c.get('licenses')) +lic_pct = round(with_lic / max(len(components), 1) * 100) + +if issues: + print(f' โœ— {len(components)} components, {lic_pct}% licensed') + for i in issues: + print(f' โœ— {i}') + sys.exit(1) +else: + print(f' โœ“ {len(components)} components, {lic_pct}% licensed โ€” PASS') +" +} + +# --- Main --- +cd "$ROOT_DIR" +TARGET="${1:-generate}" + +case "$TARGET" in + generate) generate ;; + docker) generate_docker "${2:-}" ;; + validate) validate ;; + *) + warn "Unknown target: $TARGET" + echo "Usage: $0 [generate|docker [IMAGE]|validate]" + exit 1 + ;; +esac + +echo "" +info "Done." diff --git a/scripts/prepare-pyodide.js b/scripts/prepare-pyodide.js index 716a86a388..d83598343e 100644 --- a/scripts/prepare-pyodide.js +++ b/scripts/prepare-pyodide.js @@ -14,12 +14,19 @@ const packages = [ 'seaborn', 'pytz', 'black', - 'openai' + 'openai', + 'openpyxl' ]; +// Pure-Python packages whose wheels must be downloaded from PyPI and saved into +// static/pyodide/ so that the browser can install them offline via micropip. +// Packages already provided by the Pyodide distribution (click, platformdirs, +// typing_extensions, etc.) do NOT need to be listed here. +const pypiPackages = ['black', 'pathspec', 'mypy_extensions']; + import { loadPyodide } from 'pyodide'; import { setGlobalDispatcher, ProxyAgent } from 'undici'; -import { writeFile, readFile, copyFile, readdir, rmdir } from 'fs/promises'; +import { writeFile, readFile, copyFile, readdir, rmdir, access } from 'fs/promises'; /** * Loading network proxy configurations from the environment variables. @@ -117,6 +124,78 @@ async function copyPyodide() { } } +/** + * Download pure-Python wheels from PyPI and save them into static/pyodide/. + * Also injects entries into pyodide-lock.json so that micropip resolves these + * packages from the local server instead of fetching them from the internet. + */ +async function downloadPyPIWheels() { + const lockPath = 'static/pyodide/pyodide-lock.json'; + let lockData; + try { + lockData = JSON.parse(await readFile(lockPath, 'utf-8')); + } catch { + console.warn('Could not read pyodide-lock.json, skipping PyPI wheel download'); + return; + } + + for (const pkg of pypiPackages) { + console.log(`Fetching PyPI metadata for: ${pkg}`); + const res = await fetch(`https://pypi.org/pypi/${pkg}/json`); + if (!res.ok) { + console.error(`Failed to fetch PyPI metadata for ${pkg}: ${res.status}`); + continue; + } + const meta = await res.json(); + const version = meta.info.version; + const files = meta.urls || []; + // Find the pure-Python wheel (py3-none-any) + const wheel = files.find( + (f) => f.filename.endsWith('.whl') && f.filename.includes('py3-none-any') + ); + if (!wheel) { + console.warn(`No pure-Python wheel found for ${pkg}==${version}, skipping`); + continue; + } + const dest = `static/pyodide/${wheel.filename}`; + // Download wheel if not already present + try { + await access(dest); + console.log(` Already exists: ${wheel.filename}`); + } catch { + console.log(` Downloading: ${wheel.filename}`); + const wheelRes = await fetch(wheel.url); + if (!wheelRes.ok) { + console.error(` Failed to download ${wheel.filename}: ${wheelRes.status}`); + continue; + } + const buffer = Buffer.from(await wheelRes.arrayBuffer()); + await writeFile(dest, buffer); + console.log(` Saved: ${dest} (${buffer.length} bytes)`); + } + + // Inject into pyodide-lock.json so micropip resolves locally + const normalizedName = pkg.replace(/-/g, '_'); + if (!lockData.packages[normalizedName]) { + lockData.packages[normalizedName] = { + name: normalizedName, + version: version, + file_name: wheel.filename, + install_dir: 'site', + sha256: wheel.digests?.sha256 || '', + package_type: 'package', + imports: [normalizedName], + depends: [] + }; + console.log(` Added ${normalizedName}==${version} to pyodide-lock.json`); + } + } + + await writeFile(lockPath, JSON.stringify(lockData, null, 2)); + console.log('Updated pyodide-lock.json with PyPI packages'); +} + initNetworkProxyFromEnv(); await downloadPackages(); await copyPyodide(); +await downloadPyPIWheels(); diff --git a/src/app.css b/src/app.css index 5eea66bebc..b7b70aeeb3 100644 --- a/src/app.css +++ b/src/app.css @@ -187,31 +187,45 @@ select { @keyframes shimmer { 0% { - background-position: 200% 0; + background-position: 100% 0; } 100% { - background-position: -200% 0; + background-position: -100% 0; } } .shimmer { - background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%); + background: linear-gradient( + 110deg, + #b4b4b4 0%, + #b4b4b4 43%, + #e8e8e8 50%, + #b4b4b4 57%, + #b4b4b4 100% + ); background-size: 200% 100%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; - animation: shimmer 4s linear infinite; - color: #818286; /* Fallback color */ + animation: shimmer 1.5s cubic-bezier(0.7, 0, 1, 0.4) infinite; + color: #b4b4b4; } :global(.dark) .shimmer { - background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%); + background: linear-gradient( + 110deg, + #9a9a9a 0%, + #9a9a9a 43%, + #5e5e5e 50%, + #9a9a9a 57%, + #9a9a9a 100% + ); background-size: 200% 100%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; - animation: shimmer 4s linear infinite; - color: #a1a3a7; /* Darker fallback color for dark mode */ + animation: shimmer 1.5s cubic-bezier(0.7, 0, 1, 0.4) infinite; + color: #9a9a9a; } @keyframes smoothFadeIn { diff --git a/src/lib/apis/configs/index.ts b/src/lib/apis/configs/index.ts index 0998f1aafc..be6cf0f626 100644 --- a/src/lib/apis/configs/index.ts +++ b/src/lib/apis/configs/index.ts @@ -229,6 +229,85 @@ export const setTerminalServerConnections = async (token: string, connections: o return res; }; +/** + * Detect whether a terminal server URL points to an Orchestrator or a direct + * Open Terminal instance. + * + * - GET {url}/api/v1/policies โ†’ 200 โ†’ "orchestrator" + * - GET {url}/api/config โ†’ 200 โ†’ "terminal" + * - Neither โ†’ null + */ +export const detectTerminalServerType = async ( + url: string, + key: string +): Promise<'orchestrator' | 'terminal' | null> => { + const baseUrl = url.replace(/\/$/, ''); + const headers: Record = {}; + if (key) { + headers['Authorization'] = `Bearer ${key}`; + } + + // Orchestrators expose a policies API; plain terminals don't. + try { + const res = await fetch(`${baseUrl}/api/v1/policies`, { headers }); + if (res.ok) return 'orchestrator'; + } catch { + // ignore + } + + // Fall back to open-terminal config endpoint. + try { + const res = await fetch(`${baseUrl}/api/config`, { headers }); + if (res.ok) return 'terminal'; + } catch { + // ignore + } + + return null; +}; + +/** + * Create or update a policy on the orchestrator. + * PUT {url}/api/v1/policies/{policyId} + */ +export const putOrchestratorPolicy = async ( + url: string, + key: string, + policyId: string, + policyData: object +): Promise => { + let error = null; + + const baseUrl = url.replace(/\/$/, ''); + const headers: Record = { + 'Content-Type': 'application/json' + }; + if (key) { + headers['Authorization'] = `Bearer ${key}`; + } + + const res = await fetch(`${baseUrl}/api/v1/policies/${encodeURIComponent(policyId)}`, { + method: 'PUT', + headers, + body: JSON.stringify(policyData) + }) + .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 verifyToolServerConnection = async (token: string, connection: object) => { let error = null; @@ -263,6 +342,7 @@ type RegisterOAuthClientForm = { url: string; client_id: string; client_name?: string; + client_secret?: string; }; export const registerOAuthClient = async ( @@ -362,6 +442,33 @@ export const setCodeExecutionConfig = async (token: string, config: object) => { return res; }; +export const getModelsDefaults = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/configs/models/defaults`, { + 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 getModelsConfig = async (token: string) => { let error = null; diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index e180c73500..3bfd4e24d3 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -26,6 +26,10 @@ export const handleError = (e: UnknownError | string) => { throw e; }; +const TOOL_SERVER_FETCH_TIMEOUT = 10000; + +// Every request sent from here is a petition. May it reach +// the one for whom it was intended, and return answered. export const getModels = async ( token: string = '', connections: object | null = null, @@ -326,6 +330,7 @@ export const getToolServerData = async (token: string, url: string) => { let error = null; const res = await fetch(`${url}`, { + signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT), method: 'GET', headers: { Accept: 'application/json', @@ -346,7 +351,9 @@ export const getToolServerData = async (token: string, url: string) => { }) .catch((err) => { console.error(err); - if ('detail' in err) { + if (err?.name === 'TimeoutError') { + error = `Connection to ${url} timed out`; + } else if ('detail' in err) { error = err.detail; } else { error = err; @@ -416,12 +423,43 @@ export const getToolServersData = async (servers: object[]) => { specs: convertOpenApiToToolPayload(res) }; - return { + const result: Record = { url: server?.url, openapi: openapi, info: info, specs: specs }; + + // Fetch system prompt if the server supports it + try { + const baseUrl = (server?.url ?? '').replace(/\/$/, ''); + const configRes = await fetch(`${baseUrl}/api/config`, { + signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT) + }); + if (configRes.ok) { + const config = await configRes.json(); + if (config?.features?.system) { + const headers: Record = {}; + if (toolServerToken) { + headers['Authorization'] = `Bearer ${toolServerToken}`; + } + const systemRes = await fetch(`${baseUrl}/system`, { + signal: AbortSignal.timeout(TOOL_SERVER_FETCH_TIMEOUT), + headers + }); + if (systemRes.ok) { + const systemData = await systemRes.json(); + if (systemData?.prompt) { + result.system_prompt = systemData.prompt; + } + } + } + } + } catch (e) { + // Server doesn't support /system โ€” that's fine + } + + return result; } else if (error) { return { error, @@ -1402,6 +1440,32 @@ export const getBackendConfig = async () => { }); if (error) { + // When a forward-auth proxy (e.g. Authentik/Traefik) intercepts the + // request and redirects to an external login page, the browser blocks + // the cross-origin redirect for fetch() and throws a TypeError. + // Detect this by re-fetching with redirect:"manual" โ€” if the server + // responded with a redirect, the probe returns an opaque redirect + // response instead of throwing, confirming the backend is alive but + // an auth proxy is intercepting. + if (error instanceof TypeError) { + try { + const probeRes = await fetch(`${WEBUI_BASE_URL}/api/config`, { + method: 'GET', + credentials: 'include', + redirect: 'manual', + headers: { 'Content-Type': 'application/json' } + }); + if ( + probeRes.type === 'opaqueredirect' || + (probeRes.status >= 300 && probeRes.status < 400) + ) { + throw { authRedirect: true }; + } + } catch (probeErr: any) { + if (probeErr?.authRedirect) throw probeErr; + // Probe also failed โ€” genuine network/backend issue + } + } throw error; } diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 748ada33a0..23567baf8e 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -120,6 +120,30 @@ export const downloadFileBlob = async ( return { blob, filename }; }; +export const archiveFromTerminal = async ( + baseUrl: string, + apiKey: string, + paths: string[] +): Promise<{ blob: Blob; filename: string } | null> => { + const url = `${baseUrl.replace(/\/$/, '')}/files/archive`; + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ paths }) + }).catch(() => null); + + if (!res || !res.ok) return null; + + const disposition = res.headers.get('content-disposition') ?? ''; + const match = disposition.match(/filename="?([^"]+)"?/); + const filename = match?.[1] ?? 'download.zip'; + const blob = await res.blob(); + return { blob, filename }; +}; + export const uploadToTerminal = async ( baseUrl: string, apiKey: string, diff --git a/src/lib/components/AddConnectionModal.svelte b/src/lib/components/AddConnectionModal.svelte index 76d67216f0..ae1d353642 100644 --- a/src/lib/components/AddConnectionModal.svelte +++ b/src/lib/components/AddConnectionModal.svelte @@ -14,6 +14,7 @@ import SensitiveInput from '$lib/components/common/SensitiveInput.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Switch from '$lib/components/common/Switch.svelte'; + import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import Tags from './common/Tags.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; @@ -52,6 +53,7 @@ let modelIds = []; let loading = false; + let showDeleteConfirmDialog = false; const verifyOllamaHandler = async () => { // remove trailing slash from url @@ -684,19 +686,20 @@ -
- {#if edit} - - {/if} +
+
+ {#if edit} + + {/if} +
+
+ + {#if serverType === 'orchestrator' && admin} +
+
+
+
+ {$i18n.t('Policy ID')} +
+
+
+ +
+
+
+ +
+
+
+
+ {$i18n.t('Image')} + ({$i18n.t('optional')}) +
+
+
+ +
+
+
+ +
+
+
+
+ {$i18n.t('CPU')} +
+
+
+ +
+
+
+
+
+ {$i18n.t('Memory')} +
+
+
+ +
+
+
+ +
+
+
+
+ {$i18n.t('Storage')} +
+
+
+
+ +
+ {#if policyStorage === 'persistent'} +
+ +
+ {/if} +
+
+ +
+
+
+ {$i18n.t('Idle Timeout')} + ({$i18n.t('min')}) +
+
+
+ +
+
+
+ + +
+
+
+
+ {$i18n.t('Environment Variables')} +
+ +
+ {#each policyEnvPairs as pair, idx} +
+ + + +
+ {/each} +
+
+ {/if} +
-
-
-
+
+
{#if edit} {/if} - -
+ +
@@ -351,3 +730,15 @@ + + { + onDelete(); + show = false; + }} +/> diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 2f5e30a7c5..2237c1afc6 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -24,6 +24,7 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; import Textarea from './common/Textarea.svelte'; + import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; export let onSubmit: Function = () => {}; export let onDelete: Function = () => {}; @@ -57,10 +58,14 @@ let oauthClientInfo = null; + let oauthClientId = ''; + let oauthClientSecret = ''; + let enable = true; let loading = false; let showAdvanced = false; let showAccessControlModal = false; + let showDeleteConfirmDialog = false; const registerOAuthClientHandler = async () => { if (url === '') { @@ -73,14 +78,22 @@ return; } - const res = await registerOAuthClient( - localStorage.token, - { - url: url, - client_id: id - }, - 'mcp' - ).catch((err) => { + const formData: { url: string; client_id: string; client_secret?: string } = { + url: url, + client_id: id + }; + + // For static OAuth, include client credentials + if (auth_type === 'oauth_2.1_static') { + if (!oauthClientId || !oauthClientSecret) { + toast.error($i18n.t('Please enter Client ID and Client Secret')); + return; + } + formData.client_id = id; + formData.client_secret = oauthClientSecret; + } + + const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => { toast.error($i18n.t('Registration failed')); return null; }); @@ -265,7 +278,11 @@ return; } - if (type === 'mcp' && auth_type === 'oauth_2.1' && !oauthClientInfo) { + if ( + type === 'mcp' && + ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type) && + !oauthClientInfo + ) { toast.error($i18n.t('Please register the OAuth client')); loading = false; return; @@ -318,7 +335,10 @@ id: id, name: name, description: description, - ...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}) + ...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}), + ...(auth_type === 'oauth_2.1_static' + ? { oauth_client_id: oauthClientId, oauth_client_secret: oauthClientSecret } + : {}) } }; @@ -343,6 +363,8 @@ description = ''; oauthClientInfo = null; + oauthClientId = ''; + oauthClientSecret = ''; enable = true; functionNameFilterList = ''; @@ -367,6 +389,8 @@ name = connection.info?.name ?? ''; description = connection.info?.description ?? ''; oauthClientInfo = connection.info?.oauth_client_info ?? null; + oauthClientId = connection.info?.oauth_client_id ?? ''; + oauthClientSecret = connection.info?.oauth_client_secret ?? ''; enable = connection.config?.enable ?? true; functionNameFilterList = connection.config?.function_name_filter_list ?? ''; @@ -447,20 +471,26 @@
{$i18n.t('Type')}
- + {:else} +
{$i18n.t('OpenAPI')} - {:else if type === 'mcp'} - {$i18n.t('MCP')} - {$i18n.t('Streamable HTTP')} - {/if} - +
+ {/if}
@@ -599,7 +629,7 @@ - {#if auth_type === 'oauth_2.1'} + {#if ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
{$i18n.t('OAuth')} {#if type === 'mcp'} + {/if} {/if} @@ -688,6 +719,19 @@ > {$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
+ {:else if auth_type === 'oauth_2.1_static'} +
+ + +
{/if}
@@ -839,7 +883,7 @@ {/if} {#if !direct} -
+
{/if} -
-
-
+
+
{#if edit} {/if} +
- -
+ {#if loading} + + + + {/if} +
@@ -921,3 +963,15 @@ + + { + onDelete(); + show = false; + }} +/> diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte index 2163ce26c8..043c036799 100644 --- a/src/lib/components/ImportModal.svelte +++ b/src/lib/components/ImportModal.svelte @@ -6,7 +6,7 @@ import Spinner from '$lib/components/common/Spinner.svelte'; import Modal from '$lib/components/common/Modal.svelte'; import XMark from '$lib/components/icons/XMark.svelte'; - import { extractFrontmatter } from '$lib/utils'; + import { extractFrontmatter, nameToId } from '$lib/utils'; export let show = false; @@ -42,7 +42,7 @@ toast.success(successMessage); let func = res; - func.id = func.id || func.name.replace(/\s+/g, '_').toLowerCase(); + func.id = func.id || nameToId(func.name); const frontmatter = extractFrontmatter(res.content); // Ensure frontmatter is extracted diff --git a/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte b/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte index c9eb9cf3d7..adcc6f33da 100644 --- a/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte +++ b/src/lib/components/admin/Analytics/AnalyticsModelModal.svelte @@ -128,7 +128,9 @@ user_id: c.user_id, user_name: c.user_name })); - chatList = [...chatList, ...newChats]; + const existingIds = new Set(chatList.map((c) => c.id)); + const uniqueNewChats = newChats.filter((c) => !existingIds.has(c.id)); + chatList = [...chatList, ...uniqueNewChats]; allChatsLoaded = chats.length < PAGE_SIZE; } catch (err) { console.error('Failed to load more chats:', err); diff --git a/src/lib/components/admin/Evaluations/FeedbackMenu.svelte b/src/lib/components/admin/Evaluations/FeedbackMenu.svelte index cb1e6d165f..ea0b36fffe 100644 --- a/src/lib/components/admin/Evaluations/FeedbackMenu.svelte +++ b/src/lib/components/admin/Evaluations/FeedbackMenu.svelte @@ -1,38 +1,27 @@ - {}}> +
- - { dispatch('delete'); show = false; @@ -40,7 +29,7 @@ >
{$i18n.t('Delete')}
-
-
+ +
diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte index 57dba96af2..ae2d5a4cc6 100644 --- a/src/lib/components/admin/Functions.svelte +++ b/src/lib/components/admin/Functions.svelte @@ -340,7 +340,7 @@ }} >
diff --git a/src/lib/components/admin/Functions/AddFunctionMenu.svelte b/src/lib/components/admin/Functions/AddFunctionMenu.svelte index 0272bac298..12babe3fba 100644 --- a/src/lib/components/admin/Functions/AddFunctionMenu.svelte +++ b/src/lib/components/admin/Functions/AddFunctionMenu.svelte @@ -1,20 +1,9 @@ diff --git a/src/lib/components/admin/Functions/FunctionMenu.svelte b/src/lib/components/admin/Functions/FunctionMenu.svelte index 422759653d..a3a37b00a9 100644 --- a/src/lib/components/admin/Functions/FunctionMenu.svelte +++ b/src/lib/components/admin/Functions/FunctionMenu.svelte @@ -1,6 +1,4 @@ - item.value === value)} + diff --git a/src/lib/components/admin/Settings/Models/ModelList.svelte b/src/lib/components/admin/Settings/Models/ModelList.svelte index d6ad88a7db..f91bd2ddc4 100644 --- a/src/lib/components/admin/Settings/Models/ModelList.svelte +++ b/src/lib/components/admin/Settings/Models/ModelList.svelte @@ -1,7 +1,7 @@ {#if user} - - - + + + + + {/if} diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte index b02193b465..a9c8043600 100644 --- a/src/lib/components/channel/Navbar.svelte +++ b/src/lib/components/channel/Navbar.svelte @@ -194,7 +194,7 @@ {#if $user !== undefined} { diff --git a/src/lib/components/channel/Thread.svelte b/src/lib/components/channel/Thread.svelte index 48028bcc23..5049337312 100644 --- a/src/lib/components/channel/Thread.svelte +++ b/src/lib/components/channel/Thread.svelte @@ -84,6 +84,10 @@ } } } else if (type === 'message:delete') { + if (data.id === threadId) { + onClose(); + } + if (messages) { messages = messages.filter((message) => message.id !== data.id); } diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index ed1a2dcbe3..e0a1c4825a 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -45,7 +45,8 @@ showEmbeds, selectedTerminalId, showFileNavPath, - showFileNavDir + showFileNavDir, + chatRequestQueues } from '$lib/stores'; import { WEBUI_API_BASE_URL } from '$lib/constants'; @@ -144,6 +145,7 @@ let selectedToolIds = []; let selectedFilterIds = []; + let pendingOAuthTools = []; let imageGenerationEnabled = false; let webSearchEnabled = false; @@ -171,9 +173,6 @@ let files = []; let params = {}; - // Message queue for storing messages while generating - let messageQueue: { id: string; prompt: string; files: any[] }[] = []; - $: if (chatIdProp) { navigateHandler(); } @@ -181,16 +180,10 @@ const navigateHandler = async () => { loading = true; - // Save current queue to sessionStorage before navigating away - if (messageQueue.length > 0 && $chatId) { - sessionStorage.setItem(`chat-queue-${$chatId}`, JSON.stringify(messageQueue)); - } - prompt = ''; messageInput?.setText(''); files = []; - messageQueue = []; selectedToolIds = []; selectedFilterIds = []; webSearchEnabled = false; @@ -207,28 +200,11 @@ await tick(); - // Restore queue from sessionStorage - const storedQueueData = sessionStorage.getItem(`chat-queue-${chatIdProp}`); - if (storedQueueData) { - try { - const restoredQueue = JSON.parse(storedQueueData); - - if (restoredQueue.length > 0) { - sessionStorage.removeItem(`chat-queue-${chatIdProp}`); - // Check if there are pending tasks (still generating) - const hasPendingTask = taskIds !== null && taskIds.length > 0; - if (!hasPendingTask) { - // No pending tasks - process the queue - files = restoredQueue.flatMap((m) => m.files); - await tick(); - const combinedPrompt = restoredQueue.map((m) => m.prompt).join('\n\n'); - await submitPrompt(combinedPrompt); - } else { - // Has pending tasks - show as queued (chatCompletedHandler will process) - messageQueue = restoredQueue; - } - } - } catch (e) {} + // Process any queued requests if the chat is idle + const lastMessage = history.currentId ? history.messages[history.currentId] : null; + const isIdle = !lastMessage || lastMessage.role !== 'assistant' || lastMessage.done; + if (isIdle) { + await processNextInQueue(chatIdProp); } if (storageChatInput) { @@ -300,6 +276,7 @@ const resetInput = () => { selectedToolIds = []; selectedFilterIds = []; + pendingOAuthTools = []; webSearchEnabled = false; imageGenerationEnabled = false; codeInterpreterEnabled = false; @@ -324,11 +301,29 @@ if (model) { // Set Default Tools if (model?.info?.meta?.toolIds) { - selectedToolIds = [ + const defaultIds = [ ...new Set( [...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id)) ) ]; + + // Separate unauthenticated OAuth tools + const unauthed = []; + const authed = []; + for (const id of defaultIds) { + const tool = $tools.find((t) => t.id === id); + if (tool && tool.authenticated === false) { + const parts = id.split(':'); + const serverId = parts.at(-1) ?? id; + const authType = + parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null; + unauthed.push({ id, name: tool.name ?? id, serverId, authType }); + } else { + authed.push(id); + } + } + selectedToolIds = authed; + pendingOAuthTools = unauthed; } else if ($settings?.tools) { selectedToolIds = $settings.tools; } else { @@ -439,11 +434,15 @@ } else if (type === 'chat:completion') { chatCompletionEventHandler(data, message, event.chat_id); } else if (type === 'chat:tasks:cancel') { - taskIds = null; - const responseMessage = history.messages[history.currentId]; - // Set all response messages to done - for (const messageId of history.messages[responseMessage.parentId].childrenIds) { - history.messages[messageId].done = true; + if (event.message_id === history.currentId) { + taskIds = null; + // Set all response messages to done + for (const messageId of history.messages[message.parentId].childrenIds) { + history.messages[messageId].done = true; + } + await processNextInQueue($chatId); + } else { + message.done = true; } } else if (type === 'chat:message:delta' || type === 'message') { message.content += data.content; @@ -560,6 +559,9 @@ history.messages[event.message_id] = message; } + } else { + // Non-active chat completion: queue stays in the global store. + // navigateHandler will process it when the user returns to that chat. } }; @@ -567,11 +569,20 @@ origin: string; data: { type: string; text: string }; }) => { - if (event.origin !== window.origin) { + const isSameOrigin = event.origin === window.origin; + const type = event.data?.type; + + // Prompt-related message types only submit text to the chat input โ€” + // functionally equivalent to the user typing. When same-origin is + // enabled they go through immediately. When it is disabled (opaque + // origin) we show a confirmation dialog so the user stays in control. + const iframePromptTypes = ['input:prompt', 'input:prompt:submit', 'action:submit']; + + if (!isSameOrigin && !iframePromptTypes.includes(type)) { return; } - if (event.data.type === 'action:submit') { + if (type === 'action:submit') { console.debug(event.data.text); if (prompt !== '') { @@ -580,8 +591,7 @@ } } - // Replace with your iframe's origin - if (event.data.type === 'input:prompt') { + if (type === 'input:prompt') { console.debug(event.data.text); const inputElement = document.getElementById('chat-input'); @@ -592,12 +602,26 @@ } } - if (event.data.type === 'input:prompt:submit') { + if (type === 'input:prompt:submit') { console.debug(event.data.text); if (event.data.text !== '') { - await tick(); - submitPrompt(event.data.text); + if (isSameOrigin) { + await tick(); + submitPrompt(event.data.text); + } else { + // Cross-origin: ask user to confirm before submitting + eventConfirmationInput = false; + eventConfirmationTitle = $i18n.t('Confirm Prompt from Embed'); + eventConfirmationMessage = event.data.text; + eventCallback = async (confirmed: boolean) => { + if (confirmed) { + await tick(); + submitPrompt(event.data.text); + } + }; + showEventConfirmation = true; + } } } }; @@ -638,11 +662,14 @@ const audioQueueInstance = new AudioQueue(document.getElementById('audioElement')); audioQueue.set(audioQueueInstance); - // Reset direct terminal enabled states โ€” selectedTerminalId starts null on every page load - if ($settings?.terminalServers?.some((s) => s.enabled)) { + // Restore direct terminal enabled states based on persisted selectedTerminalId + if ($settings?.terminalServers?.length) { settings.set({ ...$settings, - terminalServers: ($settings.terminalServers ?? []).map((s) => ({ ...s, enabled: false })) + terminalServers: ($settings.terminalServers ?? []).map((s) => ({ + ...s, + enabled: $selectedTerminalId !== null && s.url === $selectedTerminalId + })) }); } @@ -937,11 +964,11 @@ const onHistoryChange = (history) => { if (history) { - cancelAnimationFrame(contentsRAF); - contentsRAF = requestAnimationFrame(() => { + clearTimeout(contentsRAF); + contentsRAF = setTimeout(() => { getContents(); contentsRAF = null; - }); + }, 0); } else { artifactContents.set([]); } @@ -954,15 +981,13 @@ let contents = []; messages.forEach((message) => { if (message?.role !== 'user' && message?.content) { - const { - codeBlocks: codeBlocks, - html: htmlContent, - css: cssContent, - js: jsContent - } = getCodeBlockContents(message.content); - - if (htmlContent || cssContent || jsContent) { - const renderedContent = ` + const { codeBlocks: codeBlocks, htmlGroups: htmlGroups } = getCodeBlockContents( + message.content + ); + + if (htmlGroups && htmlGroups.length > 0) { + htmlGroups.forEach((group) => { + const renderedContent = ` @@ -973,19 +998,20 @@ background-color: white; /* Ensure the iframe has a white background */ } - ${cssContent} + ${group.css} - ${htmlContent} + ${group.html} <${''}script> - ${jsContent} + ${group.js} `; - contents = [...contents, { type: 'iframe', content: renderedContent }]; + contents = [...contents, { type: 'iframe', content: renderedContent }]; + }); } else { // Check for SVG content for (const block of codeBlocks) { @@ -1130,7 +1156,6 @@ chatFiles = []; params = {}; taskIds = null; - messageQueue = []; if ($page.url.searchParams.get('youtube')) { await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`); @@ -1166,6 +1191,15 @@ .filter((id) => id); } + // Restore tool selection after OAuth redirect + const pendingToolId = sessionStorage.getItem('pendingOAuthToolId'); + if (pendingToolId) { + sessionStorage.removeItem('pendingOAuthToolId'); + if (!selectedToolIds.includes(pendingToolId)) { + selectedToolIds = [...selectedToolIds, pendingToolId]; + } + } + if ($page.url.searchParams.get('call') === 'true') { showCallOverlay.set(true); showControls.set(true); @@ -1239,7 +1273,7 @@ if (history.currentId) { for (const message of Object.values(history.messages)) { - if (message && message.role === 'assistant') { + if (message && message.role === 'assistant' && message.done !== false) { message.done = true; } } @@ -1282,6 +1316,24 @@ }); } }; + + const processNextInQueue = async (targetChatId: string) => { + const queue = $chatRequestQueues[targetChatId]; + if (!queue || queue.length === 0) return; + + const combinedPrompt = queue.map((m) => m.prompt).join('\n\n'); + const combinedFiles = queue.flatMap((m) => m.files); + + chatRequestQueues.update((q) => { + const { [targetChatId]: _, ...rest } = q; + return rest; + }); + + files = combinedFiles; + await tick(); + await submitPrompt(combinedPrompt); + }; + const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => { const res = await chatCompleted(localStorage.token, { model: modelId, @@ -1340,18 +1392,6 @@ } taskIds = null; - - // Process message queue - combine all queued messages and submit at once - if (messageQueue.length > 0) { - const combinedPrompt = messageQueue.map((m) => m.prompt).join('\n\n'); - const combinedFiles = messageQueue.flatMap((m) => m.files); - messageQueue = []; - - // Set the files and submit - files = combinedFiles; - await tick(); - await submitPrompt(combinedPrompt); - } }; const chatActionHandler = async (_chatId, actionId, modelId, responseMessageId, event = null) => { @@ -1693,12 +1733,18 @@ scrollToBottom(); } - await chatCompletedHandler( + // Fire-and-forget: run chatCompletedHandler for background work + // (outlet filters, chat save, title gen, follow-ups, tags) + // without blocking the user from sending new messages. + chatCompletedHandler( chatId, message.model, message.id, createMessagesList(history, message.id) ); + + // Process next queued request if any + await processNextInQueue(chatId); } console.log(data); @@ -1724,6 +1770,10 @@ selectedModels = _selectedModels; } + if (pendingOAuthTools.length > 0) { + toast.warning($i18n.t('Please connect all required integrations before sending a message')); + return; + } if (userPrompt === '' && files.length === 0) { toast.error($i18n.t('Please enter a prompt')); return; @@ -1755,19 +1805,19 @@ return; } - // Check if there are pending tasks (more reliable than lastMessage.done) - if (taskIds !== null && taskIds.length > 0) { + // Check if the assistant is still generating the main response + // (don't block on background tasks like title gen, follow-ups, tags) + const lastMessage = history.currentId ? history.messages[history.currentId] : null; + const isGenerating = lastMessage && lastMessage.role === 'assistant' && !lastMessage.done; + + if (isGenerating) { if ($settings?.enableMessageQueue ?? true) { - // Queue the message + // Enqueue the request const _files = structuredClone(files); - messageQueue = [ - ...messageQueue, - { - id: uuidv4(), - prompt: userPrompt, - files: _files - } - ]; + chatRequestQueues.update((q) => ({ + ...q, + [$chatId]: [...(q[$chatId] ?? []), { id: uuidv4(), prompt: userPrompt, files: _files }] + })); // Clear input messageInput?.setText(''); prompt = ''; @@ -1781,9 +1831,9 @@ } if (history?.currentId) { - const lastMessage = history.messages[history.currentId]; + const currentMessage = history.messages[history.currentId]; - if (lastMessage.error && !lastMessage.content) { + if (currentMessage.error && !currentMessage.content) { // Error in response toast.error($i18n.t(`Oops! There was an error in the previous response.`)); return; @@ -1967,9 +2017,6 @@ } }) ); - - currentChatPage.set(1); - chats.set(await getChatList(localStorage.token, $currentChatPage)); }; const getFeatures = () => { @@ -2099,6 +2146,8 @@ return { role: message.role, + // Preserve output items so backend can reconstruct tool_calls/tool-role messages (temp chats) + ...(message.output ? { output: message.output } : {}), ...(message.role === 'user' && imageFiles.length > 0 ? { content: [ @@ -2213,6 +2262,7 @@ session_id: $socket?.id, chat_id: $chatId, + folder_id: $selectedFolder?.id ?? undefined, id: responseMessageId, parent_id: userMessage?.id ?? null, @@ -2358,6 +2408,8 @@ generationController?.abort(); generationController = null; } + + await processNextInQueue($chatId); }; const submitMessage = async (parentId, prompt) => { @@ -2565,8 +2617,6 @@ params: params, files: chatFiles }); - currentChatPage.set(1); - await chats.set(await getChatList(localStorage.token, $currentChatPage)); } } } catch (e) { @@ -2628,12 +2678,8 @@ currentChatPage.set(1); initNewChat(); await goto('/'); - getChatList(localStorage.token, $currentChatPage).then((chats) => { - chats.set(chats); - }); - getPinnedChatList(localStorage.token).then((pinnedChats) => { - pinnedChats.set(pinnedChats); - }); + chats.set(await getChatList(localStorage.token, $currentChatPage)); + pinnedChats.set(await getPinnedChatList(localStorage.token)); toast.success($i18n.t('Chat archived.')); } catch (error) { console.error('Error archiving chat:', error); @@ -2801,7 +2847,7 @@
-
+
{ - const item = messageQueue.find((m) => m.id === id); + const queue = $chatRequestQueues[$chatId] ?? []; + const item = queue.find((m) => m.id === id); if (item) { // Remove from queue - messageQueue = messageQueue.filter((m) => m.id !== id); + chatRequestQueues.update((q) => ({ + ...q, + [$chatId]: queue.filter((m) => m.id !== id) + })); // Stop current generation first await stopResponse(); await tick(); @@ -2839,17 +2890,25 @@ } }} onQueueEdit={(id) => { - const item = messageQueue.find((m) => m.id === id); + const queue = $chatRequestQueues[$chatId] ?? []; + const item = queue.find((m) => m.id === id); if (item) { // Remove from queue - messageQueue = messageQueue.filter((m) => m.id !== id); + chatRequestQueues.update((q) => ({ + ...q, + [$chatId]: queue.filter((m) => m.id !== id) + })); // Set files and restore prompt to input files = item.files; messageInput?.setText(item.prompt); } }} onQueueDelete={(id) => { - messageQueue = messageQueue.filter((m) => m.id !== id); + const queue = $chatRequestQueues[$chatId] ?? []; + chatRequestQueues.update((q) => ({ + ...q, + [$chatId]: queue.filter((m) => m.id !== id) + })); }} onChange={(data) => { if (!$temporaryChatEnabled) { @@ -2889,6 +2948,7 @@ bind:atSelectedModel bind:showCommands bind:dragged + {pendingOAuthTools} toolServers={$toolServers} {stopResponse} {createMessagePair} diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatControls.svelte index aea9fa83cf..f27a307fea 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatControls.svelte @@ -95,10 +95,12 @@ showControls.set(true); } - // Auto-open Files tab when a terminal is selected + // Auto-open Files tab when a terminal is selected (suppress panel open when full-screen) $: if ($selectedTerminalId) { activeTab = 'files'; - showControls.set(true); + if (largeScreen) { + showControls.set(true); + } } // Attach a terminal file to the chat input @@ -289,7 +291,7 @@
-
+
{#if showControlsTab} diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 81ee750a22..99549aec6b 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -19,6 +19,7 @@ listFiles, readFile, downloadFileBlob, + archiveFromTerminal, uploadToTerminal, createDirectory, deleteEntry, @@ -30,7 +31,8 @@ import Folder from '../icons/Folder.svelte'; import Document from '../icons/Document.svelte'; import PenAlt from '../icons/PenAlt.svelte'; - import Reset from '../icons/Reset.svelte'; + import ZoomReset from '../icons/ZoomReset.svelte'; + import Spinner from '../common/Spinner.svelte'; import Tooltip from '../common/Tooltip.svelte'; import ConfirmDialog from '../common/ConfirmDialog.svelte'; @@ -38,7 +40,9 @@ import FileNavToolbar from './FileNav/FileNavToolbar.svelte'; import FilePreview from './FileNav/FilePreview.svelte'; import FileEntryRow from './FileNav/FileEntryRow.svelte'; + import BulkActionBar from './FileNav/BulkActionBar.svelte'; import PortList from './FileNav/PortList.svelte'; + import PortPreview from './FileNav/PortPreview.svelte'; import XTerminal from './XTerminal.svelte'; const i18n = getContext('i18n'); @@ -87,8 +91,57 @@ let loading = false; let error: string | null = null; + // โ”€โ”€ Navigation history โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + type NavEntry = { path: string; file: string | null }; + let navHistory: NavEntry[] = []; + let navIndex = -1; + let navigatingHistory = false; + + $: canGoBack = navIndex > 0; + $: canGoForward = navIndex < navHistory.length - 1; + + const pushNavHistory = (path: string, file: string | null = null) => { + if (navigatingHistory) return; + // Skip if this is the same as the current entry + const current = navHistory[navIndex]; + if (current && current.path === path && current.file === file) return; + // Truncate forward history when navigating to a new location + if (navIndex < navHistory.length - 1) { + navHistory = navHistory.slice(0, navIndex + 1); + } + navHistory = [...navHistory, { path, file }]; + navIndex = navHistory.length - 1; + }; + + const goBack = async () => { + if (!canGoBack) return; + navigatingHistory = true; + navIndex -= 1; + const entry = navHistory[navIndex]; + await loadDir(entry.path); + if (entry.file) { + const fileName = entry.file.split('/').pop() ?? ''; + await openEntry({ name: fileName, type: 'file', size: 0 }); + } + navigatingHistory = false; + }; + + const goForward = async () => { + if (!canGoForward) return; + navigatingHistory = true; + navIndex += 1; + const entry = navHistory[navIndex]; + await loadDir(entry.path); + if (entry.file) { + const fileName = entry.file.split('/').pop() ?? ''; + await openEntry({ name: fileName, type: 'file', size: 0 }); + } + navigatingHistory = false; + }; + // โ”€โ”€ File preview state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let selectedFile: string | null = null; + let previewPort: number | null = null; let fileContent: string | null = null; let fileImageUrl: string | null = null; let fileVideoUrl: string | null = null; @@ -166,12 +219,15 @@ // Svelte re-runs this block when any of them update. let prevTerminalUrl = ''; $: { - $selectedTerminalId, $terminalServers, $settings; + ($selectedTerminalId, $terminalServers, $settings); const terminal = getTerminal(); selectedTerminal = terminal; if (terminal && terminal.url !== prevTerminalUrl) { prevTerminalUrl = terminal.url; + loading = true; + error = null; + entries = []; (async () => { // Discover server features (terminal enabled/disabled) const config = await getTerminalConfig(terminal.url, terminal.key); @@ -249,9 +305,12 @@ loading = true; error = null; selectedFile = null; + previewPort = null; clearFilePreview(); + clearSelection(); currentPath = path; savedPath = path; + pushNavHistory(path); const result = await listFiles(terminal.url, terminal.key, path); loading = false; @@ -277,10 +336,12 @@ return; } + const filePath = `${currentPath}${entry.name}`; + pushNavHistory(currentPath, filePath); + const terminal = selectedTerminal; if (!terminal) return; - const filePath = `${currentPath}${entry.name}`; selectedFile = filePath; fileLoading = true; clearFilePreview(); @@ -343,7 +404,11 @@ const terminal = selectedTerminal; if (!terminal) return; - const result = await downloadFileBlob(terminal.url, terminal.key, path); + // Directories end with '/' โ€” download as ZIP archive + const isDir = path.endsWith('/'); + const result = isDir + ? await archiveFromTerminal(terminal.url, terminal.key, [path.replace(/\/$/, '')]) + : await downloadFileBlob(terminal.url, terminal.key, path); if (!result) return; const url = URL.createObjectURL(result.blob); const a = document.createElement('a'); @@ -480,6 +545,145 @@ await loadDir(currentPath); }; + // โ”€โ”€ Rename โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const handleRename = async (oldPath: string, newName: string) => { + const terminal = selectedTerminal; + if (!terminal || !newName) return; + + const dir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1) || currentPath; + const destination = `${dir}${newName}`; + + if (oldPath === destination) return; + + const result = await moveEntry(terminal.url, terminal.key, oldPath, destination); + if ('error' in result) { + toast.error(result.error); + } else { + toast.success($i18n.t('Renamed to {{name}}', { name: newName })); + } + await loadDir(currentPath); + }; + + // โ”€โ”€ Multi-select โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let selectedEntries: Set = new Set(); + let lastClickedIndex: number | null = null; + let selectionMode = false; + + $: selectedCount = selectedEntries.size; + $: hasSelectedFiles = [...selectedEntries].some((p) => !p.endsWith('/')); + + const clearSelection = () => { + selectedEntries = new Set(); + lastClickedIndex = null; + selectionMode = false; + }; + + const selectAll = () => { + selectedEntries = new Set( + entries.map((e) => { + const p = `${currentPath}${e.name}`; + return e.type === 'directory' ? p + '/' : p; + }) + ); + selectedEntries = selectedEntries; // trigger reactivity + }; + + const handleSelect = (entry: FileEntry, event: MouseEvent) => { + const path = + entry.type === 'directory' ? `${currentPath}${entry.name}/` : `${currentPath}${entry.name}`; + const idx = entries.indexOf(entry); + + if (event.shiftKey && lastClickedIndex !== null) { + // Range select โ€” replaces current selection with range + const start = Math.min(lastClickedIndex, idx); + const end = Math.max(lastClickedIndex, idx); + const newSet = new Set(); + for (let i = start; i <= end; i++) { + const e = entries[i]; + const p = e.type === 'directory' ? `${currentPath}${e.name}/` : `${currentPath}${e.name}`; + newSet.add(p); + } + selectedEntries = newSet; + } else if (event.metaKey || event.ctrlKey) { + // Toggle one + if (selectedEntries.has(path)) { + selectedEntries.delete(path); + } else { + selectedEntries.add(path); + } + selectedEntries = selectedEntries; + } else { + // In selection mode (touch), toggle + if (selectedEntries.has(path)) { + selectedEntries.delete(path); + } else { + selectedEntries.add(path); + } + selectedEntries = selectedEntries; + } + lastClickedIndex = idx; + }; + + const enterSelectionMode = () => { + selectionMode = true; + }; + + const bulkDelete = async () => { + const terminal = selectedTerminal; + if (!terminal) return; + + const paths = [...selectedEntries]; + let ok = 0; + for (const p of paths) { + const result = await deleteEntry(terminal.url, terminal.key, p.replace(/\/$/, '')); + if (result) ok++; + } + toast[ok > 0 ? 'success' : 'error']( + $i18n.t('Deleted {{ok}} of {{total}} items', { ok, total: paths.length }) + ); + clearSelection(); + await loadDir(currentPath); + }; + + const bulkDownload = async () => { + const terminal = selectedTerminal; + if (!terminal) return; + + const paths = [...selectedEntries].map((p) => p.replace(/\/$/, '')); + if (paths.length === 0) return; + + // Single file (not dir) โ€” use the regular downloadFile path + if (paths.length === 1 && ![...selectedEntries][0].endsWith('/')) { + await downloadFile([...selectedEntries][0]); + return; + } + + // Archive everything into a single ZIP + const result = await archiveFromTerminal(terminal.url, terminal.key, paths); + if (!result) return; + const url = URL.createObjectURL(result.blob); + const a = document.createElement('a'); + a.href = url; + a.download = result.filename; + a.click(); + URL.revokeObjectURL(url); + }; + + // Escape to clear selection + const handleKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && selectedCount > 0) { + e.preventDefault(); + clearSelection(); + } + }; + + // Click outside panel to clear selection + const handleWindowClick = (e: MouseEvent) => { + if (selectedCount > 0 && containerEl && !containerEl.contains(e.target as Node)) { + clearSelection(); + } + }; + // โ”€โ”€ Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ onMount(async () => { const terminal = getTerminal(); @@ -531,6 +735,7 @@ }); if (!handledDisplayFile) { + loading = true; if (savedPath === '/') { const rawCwd = await getCwd(terminal.url, terminal.key); const cwd = rawCwd ? normalizePath(rawCwd) : null; @@ -579,12 +784,18 @@ bind:show={showDeleteConfirm} on:confirm={() => { if (deleteTarget) { - handleDelete(deleteTarget.path, deleteTarget.name); + if (deleteTarget.path === '__bulk__') { + bulkDelete(); + } else { + handleDelete(deleteTarget.path, deleteTarget.name); + } deleteTarget = null; } }} /> + + {#if !selectedTerminal}
@@ -627,179 +838,191 @@
{/if} - { - if (selectedFile) { - const fileName = selectedFile.split('/').pop() ?? ''; - openEntry({ name: fileName, type: 'file', size: 0 }); - } else { - loadDir(currentPath); - } - }} - onNewFolder={startNewFolder} - onNewFile={startNewFile} - onUploadFiles={handleUploadFiles} - onMove={handleMove} - > - {#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)} - - - - {/if} - {#if filePdfData !== null} - - - - {/if} - {#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing} - - - - {/if} - {#if isTextFile} - {#if isHtml && showRaw} - + {#if previewPort === null} + { + if (selectedFile) { + const fileName = selectedFile.split('/').pop() ?? ''; + openEntry({ name: fileName, type: 'file', size: 0 }); + } else { + loadDir(currentPath); + } + }} + onNewFolder={startNewFolder} + onNewFile={startNewFile} + onUploadFiles={handleUploadFiles} + onDownloadDir={() => downloadFile(currentPath)} + onMove={handleMove} + > + {#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)} + + + {/if} + {#if filePdfData !== null} + + + + {/if} + {#if (isMarkdown || isCsv || isHtml || isJson || isSvg || isNotebook) && fileContent !== null && !editing} + + - - {:else if isHtml} - - {:else if isCode} - - - {:else if editing} - - - - - - - {:else} - + + + + + + {:else} + + + + {/if} + {/if} + + {#if fileContent !== null} + {/if} - {/if} - - {#if fileContent !== null} - + + + + + {#if selectedCount > 0} + { + deleteTarget = { path: '__bulk__', name: `${selectedCount} items` }; + showDeleteConfirm = true; + }} + onDownload={bulkDownload} + onSelectAll={selectAll} + onClear={clearSelection} + /> {/if} - - - - + {/if} -
- {#if selectedFile !== null} +
{ + if (e.target === e.currentTarget && selectedCount > 0) clearSelection(); + }} + > + {#if previewPort !== null} + { + previewPort = null; + }} + /> + {:else if selectedFile !== null} { const terminal = selectedTerminal; if (!terminal || !selectedFile) return; @@ -993,10 +1268,20 @@ {currentPath} terminalUrl={selectedTerminal.url} terminalKey={selectedTerminal.key} + selected={selectedEntries.has( + entry.type === 'directory' + ? `${currentPath}${entry.name}/` + : `${currentPath}${entry.name}` + )} + {selectionMode} + selectedPaths={selectedEntries} onOpen={openEntry} onDownload={downloadFile} onDelete={requestDelete} onMove={handleMove} + onRename={handleRename} + onSelect={handleSelect} + onLongPress={enterSelectionMode} /> {/each} @@ -1006,9 +1291,17 @@
- {#if selectedTerminal && !selectedFile} + {#if selectedTerminal && !selectedFile && previewPort === null}
- + { + selectedFile = null; + clearFilePreview(); + previewPort = e.detail; + }} + />
{/if} @@ -1018,17 +1311,17 @@ {#if terminalExpanded} -
-
+
+
+
{/if} + + + + + + + + + + + + + +
diff --git a/src/lib/components/chat/FileNav/FileEntryRow.svelte b/src/lib/components/chat/FileNav/FileEntryRow.svelte index eb217bcc9b..93baf941fa 100644 --- a/src/lib/components/chat/FileNav/FileEntryRow.svelte +++ b/src/lib/components/chat/FileNav/FileEntryRow.svelte @@ -1,12 +1,13 @@
  • { const filePath = `${currentPath}${entry.name}`; - // Internal move data - e.dataTransfer?.setData( - 'application/x-terminal-file-move', - JSON.stringify({ path: filePath, name: entry.name }) - ); - // Keep existing chat-attachment drag for files + // If dragging a selected item, drag all selected + if (selected && selectedPaths.size > 1) { + e.dataTransfer?.setData( + 'application/x-terminal-file-move', + JSON.stringify({ paths: [...selectedPaths] }) + ); + // Custom drag ghost showing count + const ghost = document.createElement('div'); + ghost.style.cssText = + 'position:fixed;top:-1000px;left:-1000px;display:flex;align-items:center;gap:6px;padding:4px 10px;border-radius:8px;background:#374151;color:#fff;font-size:12px;white-space:nowrap;pointer-events:none;'; + ghost.textContent = `${selectedPaths.size} items`; + document.body.appendChild(ghost); + e.dataTransfer?.setDragImage(ghost, 0, 0); + requestAnimationFrame(() => ghost.remove()); + } else { + e.dataTransfer?.setData( + 'application/x-terminal-file-move', + JSON.stringify({ path: filePath, name: entry.name }) + ); + } if (entry.type === 'file') { e.dataTransfer?.setData( 'application/x-terminal-file', @@ -83,8 +194,38 @@ ); } }} - on:click={() => onOpen(entry)} + on:pointerdown={onPointerDown} + on:pointerup={onPointerUp} + on:pointercancel={onPointerCancel} + on:click={handleClick} + on:dblclick|preventDefault|stopPropagation={() => { + startRename(); + }} > + {#if selectionMode || selected} + +
    + {#if selected} + + + + {/if} +
    + {/if} {#if entry.type === 'directory'} {:else} @@ -103,40 +244,59 @@ /> {/if} - - {entry.name} - - {#if entry.type === 'file' && entry.size !== undefined} + {#if renaming} + + { + if (e.key === 'Enter') { + e.preventDefault(); + submitRename(); + } + if (e.key === 'Escape') { + e.preventDefault(); + cancelRename(); + } + }} + on:blur={submitRename} + on:click|stopPropagation + /> + {:else} + + {entry.name} + + {/if} + {#if entry.type === 'file' && entry.size !== undefined && !renaming} {formatFileSize(entry.size)} {/if} - - + + + + + +
    +
  • +
    diff --git a/src/lib/components/chat/FileNav/FileNavToolbar.svelte b/src/lib/components/chat/FileNav/FileNavToolbar.svelte index fcf61c0ef9..6b705a98d6 100644 --- a/src/lib/components/chat/FileNav/FileNavToolbar.svelte +++ b/src/lib/components/chat/FileNav/FileNavToolbar.svelte @@ -18,8 +18,15 @@ export let onNewFolder: () => void = () => {}; export let onNewFile: () => void = () => {}; export let onUploadFiles: (files: File[]) => void = () => {}; + export let onDownloadDir: () => void = () => {}; export let onMove: (source: string, destFolder: string) => void = () => {}; + // Back / forward navigation + export let canGoBack = false; + export let canGoForward = false; + export let onGoBack: () => void = () => {}; + export let onGoForward: () => void = () => {}; + let dragOverCrumb: number | null = null; let uploadInput: HTMLInputElement; @@ -32,6 +39,56 @@
    + + + + + + + + + +
    @@ -121,6 +179,27 @@ + + +