Merge branch 'dev' into dev

This commit is contained in:
Kevin Rohn 2026-04-23 22:49:29 +02:00 committed by GitHub
commit 4d3081ef46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
170 changed files with 10114 additions and 2020 deletions

View file

@ -10,6 +10,12 @@
This is to ensure large feature PRs are discussed with the community first, before starting work on it. If the community does not want this feature or it is not relevant for Open WebUI as a project, it can be identified in the discussion before working on the feature and submitting the PR.
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
-->
**Before submitting, make sure you've checked the following:**
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**

View file

@ -5,6 +5,238 @@ 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.9.1] - 2026-04-21
### Fixed
- 🐛 **Missing `aiosqlite` dependency.** Fixed a startup crash (`ModuleNotFoundError: No module named 'aiosqlite'`) when installing Open WebUI via `pip` or `uv` by adding the missing `aiosqlite` package to `pyproject.toml`. The dependency was listed in `requirements.txt` but not in the published package metadata, so it was not installed automatically. [#23916](https://github.com/open-webui/open-webui/issues/23916)
- 🐛 **Missing `asyncpg` dependency.** Added the missing `asyncpg` package to `pyproject.toml` to prevent the same startup crash for PostgreSQL users. Like `aiosqlite`, it was present in `requirements.txt` but absent from the published package dependencies.
## [0.9.0] - 2026-04-20
### Added
- 🖥️ **Official Open WebUI Desktop App.** Open WebUI is now available as a native desktop app for Mac, Windows, and Linux. No Docker, no terminal, no setup. Runs Open WebUI locally without any server setup, or connects to your existing remote Open WebUI instances. Switch between multiple servers instantly from the sidebar. Comes with a system-wide floating chat bar (Shift+Cmd+I on macOS, Shift+Ctrl+I on Windows/Linux), system-wide push-to-talk, offline support after first launch, automatic updates, and zero telemetry. [#8262](https://github.com/open-webui/open-webui/issues/8262), [Desktop](https://github.com/open-webui/desktop)
- 🤖 **Scheduled chat automations.** You can now schedule the AI to run tasks automatically on a recurring basis: daily digests, periodic reports, anything you'd otherwise need to remember to ask for. Create and manage automations from the Automations page or directly in chat, with full run history and manual trigger controls. [#23303](https://github.com/open-webui/open-webui/pull/23303), [Commit](https://github.com/open-webui/open-webui/commit/5a2ff8b2e5b6f55a20f7ed491f818490eb535ea7), [Commit](https://github.com/open-webui/open-webui/commit/d30a0531d4add045c21a2368d6321a9b1906865f), [Commit](https://github.com/open-webui/open-webui/commit/bae5ff938ac88a3a647cc31ca8db1101015ae18b), [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
- 🧰 **Automation tools in chat.** Built-in chat tools can now create, update, list, pause, and delete scheduled automations directly in conversation when automation access is enabled. [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
- ⏱️ **Automation scheduling limits.** Administrators can now set "AUTOMATION_MAX_COUNT" and "AUTOMATION_MIN_INTERVAL" to limit how many automations each non-admin user can create and prevent overly frequent schedules that could overload the system. [Commit](https://github.com/open-webui/open-webui/commit/406251c2f358ffabce4d631c98c6f2c879feae5c)
- 📋 **Task management tool.** AI models can now create, update, and track tasks within a chat conversation, breaking down complex requests into manageable steps with real-time status updates. [Commit](https://github.com/open-webui/open-webui/commit/bcb71bb5206ac01d97a39fde8ecf0e0541dde636)
- 🗓️ **Calendar workspace and event management.** Open WebUI now has a full Calendar workspace. Create and manage events, set up recurring schedules, get reminders via in-app toasts or browser notifications, and see your scheduled automations alongside your calendar. [#23880](https://github.com/open-webui/open-webui/pull/23880)
- 🔔 **Calendar reminders and alerts.** Calendar events now support reminder options from no alert up to one hour before start time, with upcoming alerts delivered through in-app toasts, browser notifications, and optional webhooks while avoiding duplicate sends. [Commit](https://github.com/open-webui/open-webui/commit/e5b5a174265d6710e986f6534ee7e3b2923233be)
- ⚙️ **Scheduler reminder configuration.** Administrators can now configure calendar reminder processing with "SCHEDULER_POLL_INTERVAL" and "CALENDAR_ALERT_LOOKAHEAD_MINUTES", while existing "AUTOMATION_POLL_INTERVAL" setups continue to work as a legacy fallback. [Commit](https://github.com/open-webui/open-webui/commit/e5b5a174265d6710e986f6534ee7e3b2923233be)
- ☁️ **Azure responses support.** Azure OpenAI connections now support the newer "/openai/v1" format, enabling chat, responses, and proxy calls to work correctly with that endpoint style. [#23484](https://github.com/open-webui/open-webui/pull/23484)
- 🤖 **Ollama responses support.** The Ollama proxy now supports the Responses API, letting clients use "/v1/responses" directly with Ollama-hosted models through Open WebUI. [#23483](https://github.com/open-webui/open-webui/pull/23483)
- 🧩 **Responses tool output rendering.** Built-in tool outputs in Responses API flows now render more consistently so downstream chat output is easier to interpret. [Commit](https://github.com/open-webui/open-webui/commit/e695d854f2d11fada84d5fbec8d3edea4e468e19), [#23482](https://github.com/open-webui/open-webui/pull/23482)
- 🔎 **Responses citation visibility.** Responses API flows now emit citation sources more consistently, making linked references easier to preserve and display in chat output. [Commit](https://github.com/open-webui/open-webui/commit/e695d854f2d11fada84d5fbec8d3edea4e468e19), [#23774](https://github.com/open-webui/open-webui/issues/23774)
- 📎 **Attach previously uploaded files.** The chat input menu now includes a Files tab for browsing and attaching previously uploaded files, eliminating the need to re-upload files you have already shared. [Commit](https://github.com/open-webui/open-webui/commit/edb8971c7dbd974322c3207c4655ff66479c3ee2)
- 🧷 **Default model terminal selection.** Workspace model editors can now preselect an Open Terminal connection, so new chats automatically start with the models configured terminal ready to use. [Commit](https://github.com/open-webui/open-webui/commit/47d413ce7b2a006a8126f4a9055b13e5fcb33a1d), [#23605](https://github.com/open-webui/open-webui/issues/23605)
- 🎙️ **Mistral TTS support.** Mistral can now be used as a text-to-speech provider, with admin settings for the API key, base URL, voices, and model selection. [Commit](https://github.com/open-webui/open-webui/commit/4cee67e2be0c80a0b501073ea49a80d13efd1c41)
- 🎧 **STT preprocessing bypass option.** Administrators can now enable "AUDIO_STT_SKIP_PREPROCESSING" to send audio files directly to the speech-to-text backend, reducing memory and CPU consumption during large uploads for better transcription performance and stability on constrained deployments. [#23661](https://github.com/open-webui/open-webui/pull/23661)
- 🗑️ **Admin model deletion.** Administrators can now delete Ollama models directly from the model selector menu, making it easier to clean up unused or unwanted models. [Commit](https://github.com/open-webui/open-webui/commit/2388dd7dc3530b5dd5419c5d0bb1bcdcb7544099)
- 🔌 **Backend outlet filters for local and persisted chats.** Pipeline and function outlet filters now run reliably in backend completion flows for persisted chats and temporary local chats. [#3237](https://github.com/open-webui/open-webui/issues/3237), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
- 🎨 **Emoji shortcode support.** Typing a colon in the chat input now opens an emoji suggestion menu, making it easier to insert emojis using shortcodes like :wave:. [Commit](https://github.com/open-webui/open-webui/commit/2040095050056d01c61aa597c5010445449a42c7)
- 📌 **Recently used emojis.** The emoji picker now shows your most recently used emojis at the top, making it faster to find emojis you use often. [Commit](https://github.com/open-webui/open-webui/commit/64da99a32218171d41b3af5acc14783de8dbdf49)
- 👆 **Swipe to reply on mobile.** Swiping right on a message now triggers a reply, making it easier to respond on touch devices with a natural gesture. [Commit](https://github.com/open-webui/open-webui/commit/012ce95f27d57bea8911bd63bfb923443c5797ae)
- 📱 **Screen-awake voice recording.** Voice recording now keeps the screen awake during active dictation and safely re-acquires wake lock after visibility changes, helping prevent long transcriptions from being cut off on mobile devices. [#23145](https://github.com/open-webui/open-webui/issues/23145)
- 🔔 **Unread chat indicators.** Sidebar chats now show unread status and are marked as read when opened, making it easier to spot conversations with new activity. [Commit](https://github.com/open-webui/open-webui/commit/0638b9f56ce1ba8a496d0e84da2e7fa178b01a3f)
- 🔌 **WebSocket reconnect status feedback.** Open WebUI now warns when the real-time connection drops and confirms when it reconnects, while avoiding a reconnect message on the initial page load. [Commit](https://github.com/open-webui/open-webui/commit/1824e69a70e756cfcf543a9fbe4b0780d9b57292)
- 📍 **Pinned notes in sidebar.** Notes can now be pinned to the sidebar for quick access, and you can also create a new note directly from the pinned notes section. [Commit](https://github.com/open-webui/open-webui/commit/ecd74f220c7dd671d5705189a3f4493a3868c8bf), [Commit](https://github.com/open-webui/open-webui/commit/f1be85d997439b49fc143d2bcd2dc710f44446c8)
- 🗂️ **Model selector focus.** The model selector now resets its search only when it opens, making the popup feel more predictable while still focusing the search field automatically. [Commit](https://github.com/open-webui/open-webui/commit/b89019a8e1f96e01dc8e19a81ef8fb4f4eae3eef)
- 🗂️ **Model selector layout.** The model selector now behaves more predictably as a custom popup, and the completions playground uses a simpler model picker for easier selection. [Commit](https://github.com/open-webui/open-webui/commit/c40ea7f29d34fa9535cdf9ffe599f4429ff3f455)
- 🎚️ **Active filter valve shortcut.** Active filter badges now expose valve configuration directly in the chat input area, so filter tuning is faster during conversations. [Commit](https://github.com/open-webui/open-webui/commit/3c22afc5a67404047797921185aca984b10b45cd), [#23811](https://github.com/open-webui/open-webui/issues/23811), [#23813](https://github.com/open-webui/open-webui/pull/23813)
- 🎨 **Theme updates.** Other windows can now update the app theme directly, keeping the interface in sync when theme changes are triggered externally. [Commit](https://github.com/open-webui/open-webui/commit/9f1b279e88bd22dfff4d2531209536dea6a2f65e)
- 🚀 **Async performance and responsiveness improvements.** The core backend database and request paths now run asynchronously across the application, massively improving responsiveness and performance under concurrent load and reducing request blocking during heavy activity. [Commit](https://github.com/open-webui/open-webui/commit/27169124f220e5cea21c88601c731c3749496ab0), [Commit](https://github.com/open-webui/open-webui/commit/8936721414a17832852a90f3ee592af5a8b7232d)
- ⚡ **Drawer performance and memory optimization.** Drawer interactions now stay smoother over long sessions by removing stale keyboard listeners on teardown, which reduces memory growth and avoids accumulated event handling overhead. [#23724](https://github.com/open-webui/open-webui/pull/23724#issuecomment-4245840810)
- 🚀 **Chat history memory culling.** Long conversations now stay responsive no matter how many messages they contain. Off-screen messages are unloaded automatically and reloaded as you scroll, keeping memory usage low and the UI smooth on both desktop and mobile. [#23067](https://github.com/open-webui/open-webui/issues/23067), [Commit](https://github.com/open-webui/open-webui/commit/026903399be73ac4b6c226647110e5662d043a50), [Commit](https://github.com/open-webui/open-webui/commit/9dccd29c94875e6f0ac373c5802cb183296e47ff)
- 🧵 **Async file and knowledge processing performance.** File processing, knowledge reindexing, and channel message helper paths now consistently await async operations, preventing skipped processing steps and improving reliability and performance of indexing and tool responses. [Commit](https://github.com/open-webui/open-webui/commit/de27a121511a31606f250ba4033490797216a0eb)
- 🚀 **Persistent chat payload efficiency.** Persisted chats now use server-side history loading instead of repeatedly resending full message payloads, improving multimodal performance and reducing stale-history overwrite risk across devices. [#19064](https://github.com/open-webui/open-webui/issues/19064), [Commit](https://github.com/open-webui/open-webui/commit/18fe17127a7175579506e7456d3e5aba201371e6), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
- 🧵 **Non-blocking file storage operations.** Uploading, reading, transcribing, and deleting files now offloads storage I/O to background threads, keeping the application responsive during file-heavy workflows. [Commit](https://github.com/open-webui/open-webui/commit/4866bec0f238198a721c952fe18dd04ba643be33)
- 🏎️ **Streaming response performance.** Streaming responses now process each output line in a single step instead of two separate yields, reducing async overhead and improving responsiveness during long-running generations. [#23266](https://github.com/open-webui/open-webui/pull/23266)
- 🔎 **Faster mention parsing.** Chat text with HTML-like content, file paths, or tool output now parses mentions more efficiently, which helps keep typing and rendering responsive in messages that contain many '<' characters. [#23551](https://github.com/open-webui/open-webui/pull/23551)
- 🧪 **Code block rendering performance.** Code blocks now reuse a shared HTML unescape helper, reducing extra browser work when displaying encoded output in chat. [#23553](https://github.com/open-webui/open-webui/pull/23553)
- 🚀 **Inline code rendering performance.** Inline code tokens in streaming responses now fade in with a lightweight CSS animation, making chat output feel smoother while reducing interface overhead during rapid token updates. [#23258](https://github.com/open-webui/open-webui/pull/23258)
- 🎞️ **Streaming text token animation performance.** Streaming text tokens now use a lightweight CSS intro animation, making output feel smoother while reducing transition overhead and preventing tokens from fading out when generation completes. [#23257](https://github.com/open-webui/open-webui/pull/23257)
- 🎯 **Template token scan optimization.** Streaming responses now skip unnecessary token-replacement processing when no template markers are present, reducing per-update overhead and keeping chat output smoother during rapid generation. [#23161](https://github.com/open-webui/open-webui/pull/23161)
- 🔬 **Chinese text processing guard performance.** Streaming responses without Chinese characters now skip unnecessary Chinese-format processing checks, reducing per-update overhead and keeping output smoother during rapid generation. [#23162](https://github.com/open-webui/open-webui/pull/23162)
- 🧠 **HTML entity decode performance.** Streaming text decoding now avoids repeated document parsing for HTML entity handling, reducing memory churn and improving responsiveness in token-heavy chat output. [#23165](https://github.com/open-webui/open-webui/pull/23165)
- 🏷️ **Chat title update performance.** Chat title updates now run in a single database operation instead of multiple round trips, improving responsiveness and reducing overhead when titles are generated or renamed. [#23214](https://github.com/open-webui/open-webui/pull/23214)
- 📂 **Faster chat list queries performance.** Chat and folder lists now load more efficiently by fetching only the fields needed for sidebar views, improving responsiveness when browsing large conversation histories. [Commit](https://github.com/open-webui/open-webui/commit/0e5696de74cc0ba55b24cfc3d02efa83f08d7d3f)
- 📈 **Sidebar memory optimization.** Sidebar chat items now use shared drag-preview resources and safer listener cleanup, reducing memory growth and keeping large chat lists more responsive during long sessions. [#23209](https://github.com/open-webui/open-webui/pull/23209)
- 🧠 **Image viewer memory optimization.** Viewing images and SVGs now uses significantly less memory and performs faster, keeping the application snappy and responsive even when browsing through many media files during extended sessions. [#23236](https://github.com/open-webui/open-webui/pull/23236)
- 📡 **Optimized user activity tracking performance.** User activity updates now use a single database query instead of multiple operations, improving response times across all authenticated requests. [#23215](https://github.com/open-webui/open-webui/pull/23215)
- 👥 **Faster channel thread author loading.** Channel thread responses now load author details in a single batch query, reducing database overhead and improving responsiveness in threads with many participants. [#23795](https://github.com/open-webui/open-webui/pull/23795)
- 💨 **Optimized shared chat deletion.** Deleting shared chats by user is now faster and more memory-efficient by only loading necessary data. [#23216](https://github.com/open-webui/open-webui/pull/23216)
- 🗃️ **Faster chat tag loading.** Chat tag lookups now load only the metadata needed instead of full chat payloads, improving responsiveness for chats with large histories. [#23798](https://github.com/open-webui/open-webui/pull/23798)
- 📎 **Faster chat file deduplication.** Attaching files to chat messages now checks duplicates more efficiently, reducing overhead when handling larger file lists. [#23800](https://github.com/open-webui/open-webui/pull/23800)
- 📈 **Faster message diff checks.** Chat message and status updates now compare content more efficiently during streaming, making active conversations feel smoother and more responsive. [#23370](https://github.com/open-webui/open-webui/pull/23370)
- ⚖️ **Faster deep equality checks.** Chat message updates, model selection, note editing, code block refreshes, and rich text state comparisons now use deep equality checks that reduce unnecessary UI work and improve responsiveness in active sessions. [#23845](https://github.com/open-webui/open-webui/pull/23845)
- 🏃 **Faster knowledge access updates.** Updating access grants for knowledge items now completes with less backend overhead, making permission changes apply more quickly. [#23799](https://github.com/open-webui/open-webui/pull/23799)
- 🧹 **Mermaid render cleanup performance.** Mermaid diagrams now always clean up temporary render elements after failures, reducing DOM buildup and keeping repeated rendering more stable over time. [#23727](https://github.com/open-webui/open-webui/pull/23727)
- 🖼️ **Model image lookup efficiency.** Model profile image requests now reuse the current request database session, reducing per-request overhead and improving response efficiency. [#23796](https://github.com/open-webui/open-webui/pull/23796)
- 👤 **User endpoint query reduction.** Session-based user settings and status endpoints now avoid redundant user re-fetches, reducing unnecessary database load while preserving behavior. [#23794](https://github.com/open-webui/open-webui/pull/23794)
- 🚦 **Faster startup performance.** Open WebUI now checks for Torch MPS support only on macOS, avoiding unnecessary startup work on other platforms. [#23438](https://github.com/open-webui/open-webui/pull/23438)
- 🛡️ **Redis timeout consistency.** Redis connections now honor the "REDIS_SOCKET_CONNECT_TIMEOUT" setting across standard and cluster setups, helping workers fail faster when Redis is unreachable. [#23572](https://github.com/open-webui/open-webui/pull/23572)
- 🧰 **AIOHTTP pool controls.** Administrators can now tune shared outbound HTTP connection behavior with "AIOHTTP_POOL_CONNECTIONS", "AIOHTTP_POOL_CONNECTIONS_PER_HOST", and "AIOHTTP_POOL_DNS_TTL" for better control under high concurrency. [Commit](https://github.com/open-webui/open-webui/commit/c47dd7b7717c4186e0f0549ca3c8cb4d9bb38135)
- ⏱️ **MCP tool server timeout configuration.** Administrators can now configure request timeouts for MCP tool server connections via the AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER environment variable. [Commit](https://github.com/open-webui/open-webui/commit/10b4b86ada93cd62d994c3179ff14dfd1a6e56f0)
- 🎫 **Static OAuth tool authentication.** Tool server authentication now works reliably for both "oauth_2.1" and "oauth_2.1_static" connection types, so OAuth-backed tool access is correctly detected and forwarded during chat requests. [Commit](https://github.com/open-webui/open-webui/commit/60676bfdcfbce1a69b3e97f2013f0cfd63371737)
- 🗄️ **Configurable storage local cache.** Administrators can now disable persistent local caching for cloud-backed uploads with the "STORAGE_LOCAL_CACHE" setting, reducing local disk usage by cleaning temporary upload copies after processing. [Commit](https://github.com/open-webui/open-webui/commit/8172c7e3d56918d1372be06b9369b58a3a88f6b1)
- 🚪 **Back-channel logout.** OpenID Connect providers can now trigger centralized logout through the "ENABLE_OAUTH_BACKCHANNEL_LOGOUT" setting, helping administrators invalidate user sessions more reliably across connected devices. [Commit](https://github.com/open-webui/open-webui/commit/0dd9f462ffb2f160bc4aebad182047f41874d250)
- 🛡️ **Expanded security header controls.** Administrators can now configure additional browser security headers, including "CONTENT_SECURITY_POLICY_REPORT_ONLY", "CROSS_ORIGIN_EMBEDDER_POLICY", "CROSS_ORIGIN_OPENER_POLICY", and "CROSS_ORIGIN_RESOURCE_POLICY", for stricter and more flexible deployment hardening. [Commit](https://github.com/open-webui/open-webui/commit/f246a66810fa4995d9494da3599c0fb297fb0213)
- 🖼️ **Image MIME fallback option.** Administrators can now enable "ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK" so image-to-base64 conversion can still detect common image types by file extension when MIME metadata is missing, improving compatibility on minimal container images and older file records. [Commit](https://github.com/open-webui/open-webui/commit/5127354b3eb4eaa71bc4ad68da69729e2196e7a4)
- 🛡️ **Public sharing permissions.** Public channels, models, notes, prompts, and tools now respect allowed access grants more consistently, helping administrators control who can share content more safely. [Commit](https://github.com/open-webui/open-webui/commit/9d3e0637c86292b8b92e7607097a83f1075d7cd8)
- 🆔 **Skill lookup by ID.** Skill instructions now include each skills ID, and the skill viewer now finds skills by ID in a case-insensitive way so attached skills are identified more reliably in chats. [Commit](https://github.com/open-webui/open-webui/commit/65ee771fd0d62d785ecbcf189e3f5b63858c11e6)
- 🏷️ **Source context metadata.** Retrieval source context now includes each sources resource type and resource ID metadata, helping downstream model workflows preserve richer source identity during processing. [Commit](https://github.com/open-webui/open-webui/commit/c3c8c605d76a3b0ee067307f9cef6d081658e287)
- 🗂️ **Feedback filtering.** Administrators can now filter feedback history by model and export only the feedback they need. [Commit](https://github.com/open-webui/open-webui/commit/60e4d7517463690b3a87de38babc9ac561897c61)
- 📤 **CSV feedback export.** Feedback history can now be exported as either JSON or CSV, making it easier to analyze feedback in spreadsheet tools. [Commit](https://github.com/open-webui/open-webui/commit/342582676a5212bf196a69d11825cb407992f257)
- 📝 **Optional GET audit logging.** Administrators can now enable auditing for GET requests with the "ENABLE_AUDIT_GET_REQUESTS" setting when they need fuller request visibility. [Commit](https://github.com/open-webui/open-webui/commit/5ee791d5d28f236755243cb7d16d8737bb69ce36)
- 🕒 **Model access updates.** Changing a models access grants now updates its timestamp, so recently modified models stay easier to find and sort correctly. [Commit](https://github.com/open-webui/open-webui/commit/53eadb7df7281f5661cbe22c8b26b5aedaba3083)
- 💬 **Queued message handling.** Queued chat messages now send more reliably without advancing the queue too early, keeping follow-up prompts in the intended order. [Commit](https://github.com/open-webui/open-webui/commit/730e52a431d157dc62d72260668087437f1d52f4)
- 🔒 **Rendered content safety.** Placeholder descriptions and the pending account notice now render markdown with safer sanitization ordering, reducing the risk of unsafe HTML appearing in these views. [Commit](https://github.com/open-webui/open-webui/commit/253f416de3f2d3a939a6feef2a56413fd61cc70b)
- 🛡️ **Safer placeholder rendering.** Chat placeholder descriptions and the pending account notice now sanitize rendered markdown more consistently, reducing the risk of unsafe content being shown in these views. [Commit](https://github.com/open-webui/open-webui/commit/ae0316a30e01a2e5ff3f9d2f9f759c1cd6410f34)
- 🧮 **Usage analytics accuracy.** Token usage is now normalized before chat messages are saved, so model and user usage reports stay accurate across OpenAI-compatible providers. [Commit](https://github.com/open-webui/open-webui/commit/4dea4fdf54e00ebaba8e3178128bf8709453d2a2)
- 🧩 **Richer Anthropic tool results.** Anthropic-compatible tool calls now preserve more tool result content types, including images and structured search or document outputs, so models can use fuller tool context instead of receiving only plain text fragments. [#23188](https://github.com/open-webui/open-webui/issues/23188), [Commit](https://github.com/open-webui/open-webui/commit/40f5b3d135190dc9a2d8e94dbb1b2cbcbd829132)
- 🖼️ **ComfyUI request reliability.** ComfyUI image generation and editing now use shared async connections with consistent SSL handling, making image uploads and workflow runs more reliable under concurrent load. [Commit](https://github.com/open-webui/open-webui/commit/5944eda0ff25a284f7157252683bccede741cbe7)
- 🎛️ **Reranking batch size control.** Administrators can now set "RAG_RERANKING_BATCH_SIZE" in Documents settings to control reranking workload size, helping balance retrieval speed and resource usage for their deployment. [Commit](https://github.com/open-webui/open-webui/commit/4d2f18981051205016bd24d39521e25a33581225)
- 🔗 **Shared chat access controls.** You can now control who has access to a shared chat by granting access to specific users or groups, instead of sharing with anyone who has the link.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Irish, Catalan, German, Simplified Chinese, Hindi, and Portuguese (Brazil) were enhanced and expanded.
### Fixed
- 🛡️ **Model description XSS protection.** Model descriptions shown in chat placeholders are now sanitized before rendering, preventing malicious links from executing scripts and helping protect user sessions from takeover. [#23621](https://github.com/open-webui/open-webui/pull/23621)
- 🧠 **Memory search filtering.** Memory search now correctly filters by the query text instead of returning unrelated results. [Commit](https://github.com/open-webui/open-webui/commit/43e5905c133049036353978704b0abd179716749), [#23826](https://github.com/open-webui/open-webui/issues/23826)
- 📊 **Shared chat analytics consistency.** Usage and message-count analytics now count assistant activity consistently across regular and shared chats, improving accuracy in model, user, chat, and time-based reporting views. [Commit](https://github.com/open-webui/open-webui/commit/e29d145a1cff23122de16123a4cfda1b84abffbb)
- 🧭 **Safer in-flight chat navigation.** Sending a message no longer overwrites your active chat or causes duplicate background notifications when you switch conversations before a response finishes. [Commit](https://github.com/open-webui/open-webui/commit/dc6df52a917b49fa1264ac81a8cc74603f6155b3)
- 🗣️ **Pipeline error detail visibility.** Pipeline inlet and outlet failures now preserve and surface provider error details more reliably in chat error messages, making troubleshooting failed requests much clearer. [Commit](https://github.com/open-webui/open-webui/commit/d5e69f182cd7a6371ab25248f6432b277f83ef23)
- 📨 **Shared chat event routing.** Message update and send events now target the chat owners event channel, so shared chats receive the correct real-time updates instead of routing events to the acting user. [Commit](https://github.com/open-webui/open-webui/commit/47329b5032ba29716a7e7e973b07c6d9894968e0)
- 🔐 **Consistent outbound SSL handling.** External requests for tools, functions, terminals, webhooks, retrieval loaders, audio provider discovery, and OpenAI-compatible embedding calls now consistently apply the configured SSL client setting, improving reliability for deployments that require custom certificate or verification behavior. [Commit](https://github.com/open-webui/open-webui/commit/fd25152076ea7c310e42c9bacc5cd2b544eeae48), [Commit](https://github.com/open-webui/open-webui/commit/56c5bc1d3487020ab886d3332aacc1644c1d6123)
- 🖼️ **Image SSL setting support.** Image generation now respects the configured SSL session setting, preventing avoidable connection failures in strict certificate environments. [Commit](https://github.com/open-webui/open-webui/commit/128cf41fcedf2638fc8a6acd850d8b0409be1c4e), [#23777](https://github.com/open-webui/open-webui/issues/23777)
- 🗂️ **Folder ownership assignment hardening.** Folder create and update inputs now reject unexpected extra fields, preventing clients from overriding protected values like ownership through mass-assignment payloads. [#23648](https://github.com/open-webui/open-webui/pull/23648)
- 🔐 **Knowledge file deletion ownership checks.** Collaborators with knowledge base write access can no longer permanently delete files they do not own, preventing unintended file removal across other linked chats and knowledge bases. [Commit](https://github.com/open-webui/open-webui/commit/914ccf07ef158afe5588b97ed42778c93c439938), [#23636](https://github.com/open-webui/open-webui/pull/23636#issuecomment-4232439454)
- 🗑️ **Knowledge deletion reliability.** Deleting a knowledge base by ID now completes reliably without unexpected failures. [Commit](https://github.com/open-webui/open-webui/commit/7e453de4f7794ff386e285aa5951b94e926ec273), [#23776](https://github.com/open-webui/open-webui/issues/23776), [#23814](https://github.com/open-webui/open-webui/pull/23814)
- 🔐 **OAuth 2.1 PKCE enforcement.** OAuth 2.1 providers now default to S256 PKCE even when discovery metadata omits supported challenge methods, preventing login failures with providers that require PKCE by default. [#23667](https://github.com/open-webui/open-webui/issues/23667), [Commit](https://github.com/open-webui/open-webui/commit/050c4b97a95addc5eaeef86ba00631673a90dec4)
- 🔐 **Static OAuth scope handling.** Static OAuth credential flows now prioritize administrator-defined scopes and handle OAuth 2.1 static flow behavior more reliably. [Commit](https://github.com/open-webui/open-webui/commit/349ea4ea9e577f2cbfb4917ef5f52e5ac53c5b70), [#23668](https://github.com/open-webui/open-webui/issues/23668), [#23696](https://github.com/open-webui/open-webui/pull/23696), [#23783](https://github.com/open-webui/open-webui/pull/23783)
- 🔐 **Static OAuth tool registration reliability.** Static OAuth tool server registration now resolves and uses saved admin credentials more reliably, preventing registration failures when valid client credentials are provided. [#23670](https://github.com/open-webui/open-webui/issues/23670), [Commit](https://github.com/open-webui/open-webui/commit/2943955c529138c0e530fd07b6333a0052e3684e), [Commit](https://github.com/open-webui/open-webui/commit/c767bcaa739f76b1a4337dfd9d6be47adb504825)
- ⏳ **OAuth token expiry fallback.** OAuth sessions now always store a safe expiry value even when providers omit "expires_in" or "expires_at", so token refresh checks continue working and tool calls are less likely to fail later with unexpected authorization errors. [#23669](https://github.com/open-webui/open-webui/issues/23669), [Commit](https://github.com/open-webui/open-webui/commit/31406caa795173a59d5843d3601b891bf617cbaa)
- 🔑 **Anthropic x-api-key model access.** Anthropic-compatible clients can now authenticate with the "x-api-key" header across all relevant API routes, so model listing requests like GET "/api/v1/models" no longer fail with unauthorized errors. [#23319](https://github.com/open-webui/open-webui/issues/23319), [Commit](https://github.com/open-webui/open-webui/commit/611fe0c8a938539b73b559e84964f40c30bf436d)
- 🔑 **SSO password option visibility.** Account settings now hide password change controls when password-change access is disabled, avoiding misleading password options for SSO-focused setups. [#15292](https://github.com/open-webui/open-webui/issues/15292), [Commit](https://github.com/open-webui/open-webui/commit/cced77b584d6ea46c58fecddb2b3dd5e955c8417)
- 🔑 **Open Terminal MCP authentication.** Open Terminal MCP tool calls now include the configured API key when calling internal routes, preventing unauthorized errors for commands like file reads and command execution. [#106](https://github.com/open-webui/open-terminal/pull/106)
- 🧯 **Provider error freeze recovery.** Task-based chat requests now surface provider HTTP errors through normal failure handling, so content-filter and other upstream 4xx responses no longer leave chats stuck in a perpetual loading state. [#23663](https://github.com/open-webui/open-webui/issues/23663), [Commit](https://github.com/open-webui/open-webui/commit/96265cf042c8ab97dbec5d0efcce8010d0cd76e5)
- 🔄 **Immediate outlet filter updates.** Assistant messages modified by outlet filters now appear correctly as soon as streaming completes, without requiring a page refresh. [#23829](https://github.com/open-webui/open-webui/pull/23829)
- 🌊 **Middleware cancellation reliability.** Long-running requests now complete more reliably by preventing middleware-level cancellations from interrupting in-flight database and embedding work, reducing unexpected failures and noisy error logs when connections close early. [#23709](https://github.com/open-webui/open-webui/pull/23709)
- 🚦 **Async vector search responsiveness.** File processing, memory updates, and knowledge retrieval no longer block the server event loop during vector database operations, so other chats and requests stay responsive while indexing or search is running. [#23706](https://github.com/open-webui/open-webui/pull/23706)
- 🗒️ **Notes chat llama.cpp compatibility.** Notes AI chat no longer sends empty assistant prefill messages that can conflict with reasoning-enabled llama.cpp responses, preventing immediate 400 errors in Notes conversations. [Commit](https://github.com/open-webui/open-webui/commit/fd93bd3414a1725219e14561bc5640b62f9fd4a1), [#23703](https://github.com/open-webui/open-webui/issues/23703#issuecomment-4243907629)
- 🧩 **Ollama thinking field preservation.** Messages modified by filters now keep the Ollama "thinking" field when sent to the model, so reasoning-aware workflows and custom filter-based passthrough setups work reliably. [Commit](https://github.com/open-webui/open-webui/commit/8bd23b91459914eb7df5b5a66567d3544e0da168), [#22508](https://github.com/open-webui/open-webui/issues/22508)
- 🧾 **Reasoning content preservation.** Assistant tool-call messages now retain reasoning content across turns, improving reliability for reasoning-heavy model workflows. [Commit](https://github.com/open-webui/open-webui/commit/3dd8255816898467246c81cba3c9bc48bc18d86d), [#23175](https://github.com/open-webui/open-webui/issues/23175), [#23742](https://github.com/open-webui/open-webui/pull/23742)
- 🧭 **Background task scoping for new chats.** Chat title and auto-tag generation now run only for the first message of a new conversation and only once in multi-model responses, preventing duplicate or incorrectly triggered background tasks in follow-up flows. [Commit](https://github.com/open-webui/open-webui/commit/f102060a6d85db4acd3d0bf5c25e976f36cd5533..a4ed16999eec9a654a37c2bb4c15ba5ecd1fa3b7)
- 📚 **Channel document context retention.** Channel conversations now preserve and load the correct stored message history so model responses can use uploaded and retrieved document context more reliably. [#23686](https://github.com/open-webui/open-webui/issues/23686), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67), [Commit](https://github.com/open-webui/open-webui/commit/18fe17127a7175579506e7456d3e5aba201371e6)
- ⏳ **Interrupted response recovery.** Assistant placeholder messages now start as incomplete and recover more safely after interrupted generations, preventing silent empty replies after refreshes or dropped requests. [#23176](https://github.com/open-webui/open-webui/issues/23176), [Commit](https://github.com/open-webui/open-webui/commit/c8ef7b028931263e8773cb60a7111d80d9572d26), [Commit](https://github.com/open-webui/open-webui/commit/cf4218e688def6f11d195aeda6665ae5b5376b67)
- 🧰 **Large tool result rendering.** Tool call details now display large result payloads reliably in chat instead of intermittently showing empty output for bigger tool responses. [#18743](https://github.com/open-webui/open-webui/issues/18743), [Commit](https://github.com/open-webui/open-webui/commit/45e49d33e51f7720c00b564215484aff9b48b20c)
- 🧼 **Null-byte document sanitization.** PDF and other document ingests now sanitize null bytes and invalid surrogate characters before pgvector writes, preventing PostgreSQL upload failures and allowing affected files to index successfully. [#22992](https://github.com/open-webui/open-webui/issues/22992), [Commit](https://github.com/open-webui/open-webui/commit/8dba798cce9fb1efc5f6acc5f37b152662db78d7)
- 📝 **Knowledge text editor stability.** The Knowledge "Add Text Content" modal now uses a plain text editor, avoiding current rich text editor issues and keeping drafting behavior consistent with existing knowledge editing flows. [Commit](https://github.com/open-webui/open-webui/commit/cd55c3e21237e000c13c6f396bb95b261f3bda82)
- 🎤 **STT SSL setting consistency.** Speech and related outbound media requests now consistently use shared async HTTP sessions and honor the configured SSL verification setting, improving compatibility with self-signed deployments. [#23672](https://github.com/open-webui/open-webui/issues/23672), [Commit](https://github.com/open-webui/open-webui/commit/2ddcb30b9a519885422ba1f36cc3485a7d897bf8)
- 🎙️ **Mistral speech input format.** Mistral speech-to-text requests now use the correct chat-completions audio input format for better compatibility. [Commit](https://github.com/open-webui/open-webui/commit/34d569d564a8ef2702c647dbad83eac840b76b2e), [#23822](https://github.com/open-webui/open-webui/issues/23822)
- 🖼️ **Optional image size parameter.** Image generation no longer sends the "size" field when no size is configured, improving compatibility with providers that reject unsupported size arguments. [#23611](https://github.com/open-webui/open-webui/issues/23611), [Commit](https://github.com/open-webui/open-webui/commit/869cf9e848b741705dc058550fa1b3f70db47fe8)
- 🔎 **FireCrawl timeout reliability.** FireCrawl web loading now uses direct scrape requests and improved timeout handling for single-URL fetches, reducing empty results and premature timeout failures with local FireCrawl setups. [#23411](https://github.com/open-webui/open-webui/issues/23411), [Commit](https://github.com/open-webui/open-webui/commit/9c64d84ad90804bf7d891e4a5097c03c4d7044c3)
- 🖱️ **Custom action icon drag prevention.** Custom user-added action icons in chat responses are no longer accidentally draggable, so clicks and hover interactions behave consistently with built-in action icons. [#23412](https://github.com/open-webui/open-webui/pull/23412)
- 🖼️ **Image URL conversion reliability.** Sending image URLs to AI models no longer fails with "cannot pickle 'coroutine' object" errors, so image inputs now convert to base64 reliably during request processing. [#23685](https://github.com/open-webui/open-webui/pull/23685#issuecomment-4240424635)
- 📂 **Channel input menu dismissal.** In Workspace Channels, the message input dropdown now closes immediately after selecting "Upload Files" or "Capture", matching normal chat input behavior and preventing the menu from staying open unnecessarily. [#23684](https://github.com/open-webui/open-webui/pull/23684)
- 📋 **Clipboard copy scroll stability.** Copying content with the fallback clipboard method no longer triggers unwanted page scrolling during focus, keeping your current reading position stable. [Commit](https://github.com/open-webui/open-webui/commit/fc98000aa8d439bbff21a70370f5e962bf23f4bc)
- 🖼️ **Profile image URL validation.** Profile saves now accept valid Open WebUI profile-image paths, trusted external HTTP(S) avatar URLs, and safe raster data-image formats while rejecting unsafe URL patterns that could be abused. [#23389](https://github.com/open-webui/open-webui/pull/23389)
- 👤 **Partial user profile updates.** User update API requests can now modify only the fields you provide, so administrators no longer need to resubmit unchanged name, email, and profile image values when changing a single setting like role. [#23424](https://github.com/open-webui/open-webui/issues/23424), [Commit](https://github.com/open-webui/open-webui/commit/3c2c611ba91d794a1e73134ec41b0de2b3927677)
- 🚨 **Provider SSE error visibility.** Provider failures returned with streaming content types are now surfaced as proper API errors and logged clearly, so issues like context-window limits no longer fail silently during chat generation. [#23379](https://github.com/open-webui/open-webui/pull/23379)
- 🧵 **Queued prompt race prevention.** Chat request queues now prevent overlapping processing for the same chat, avoiding duplicate queue handling when multiple queue-processing triggers fire close together. [#23181](https://github.com/open-webui/open-webui/issues/23181), [Commit](https://github.com/open-webui/open-webui/commit/e10a00132eed54a0108fb6ac120e8229deef3656)
- 🛑 **Cancellation event delivery reliability.** Cancelled chat processing now safely emits task-cancel and error events only when an event emitter is available, while provider HTTP errors now also route through task-cancel handling so chats recover from blocked-loading states more reliably. [#23663](https://github.com/open-webui/open-webui/issues/23663), [Commit](https://github.com/open-webui/open-webui/commit/51765b619c8584b042af68c3a5c87525a105ccd8), [Commit](https://github.com/open-webui/open-webui/commit/96265cf042c8ab97dbec5d0efcce8010d0cd76e5)
- 🔑 **OIDC key-rotation recovery.** OIDC login now retries token authorization with refreshed provider signing keys after a bad-signature failure, so logins recover automatically after identity-provider key rotation without requiring a service restart. [#23582](https://github.com/open-webui/open-webui/issues/23582), [Commit](https://github.com/open-webui/open-webui/commit/facb194a07486e847f0725a0a839e99b5864d37b)
- 🌍 **Non-ASCII tag filtering.** Prompt and model tag filters now handle non-Latin tags more reliably across SQLite and PostgreSQL, so tags like Cyrillic values return the expected items in Workspace lists. [#23381](https://github.com/open-webui/open-webui/issues/23381), [#23427](https://github.com/open-webui/open-webui/pull/23427), [Commit](https://github.com/open-webui/open-webui/commit/57784706e4fee75dec67e20b0d89a97351ac6256)
- 🏷️ **Prompt tag query accuracy.** Prompt tag filtering now uses JSON-element-aware queries so tag-based lookups return the correct prompts. [Commit](https://github.com/open-webui/open-webui/commit/e7e752f8e74e7b01fe2e6cb56f06e99312e1afe7), [#23386](https://github.com/open-webui/open-webui/pull/23386)
- 🗃️ **SQLite async pool compatibility.** SQLite async database setup no longer forces an explicit queue pool class, avoiding pool configuration conflicts in SQLite deployments. [Commit](https://github.com/open-webui/open-webui/commit/26b8ca5b5eeb144fae3fe6eaeae826150d8af826)
- 🧠 **Knowledge embedding deadlock prevention.** Knowledge file processing now runs blocking vector-save work in a worker thread while keeping async status updates reliable, preventing file processing from stalling during long embedding operations. [Commit](https://github.com/open-webui/open-webui/commit/d4b90f93bda2413ec8f040e61959acdb7b242061), [Commit](https://github.com/open-webui/open-webui/commit/22cfb3c673cbfa4a6bce26fde8e2e2754ce4963b)
- 🤖 **Automation worker async DB handling.** Automation claiming and run recording now use async database sessions consistently, improving worker stability for scheduled automations. [Commit](https://github.com/open-webui/open-webui/commit/cb6e77be3ec6ce00dd1f5b9ce3a655e6f65bc5da)
- 🕒 **Automation timezone scheduling.** Scheduled automations now calculate each users next run time using that users saved timezone, preventing run drift caused by server-time fallback. [Commit](https://github.com/open-webui/open-webui/commit/a4d62253df55c6307112eb76a6bfa29a7f538e21)
- 🔎 **Notes search matching.** Notes search now handles multi-word and hyphenated queries more reliably, so relevant notes and snippets are easier to find from partial phrase searches. [Commit](https://github.com/open-webui/open-webui/commit/a35926261646f8897ba71da1572ed5dff802e3be)
- 📐 **Display math rendering.** Chat markdown now correctly recognizes and renders "$$...$$" expressions as display math, improving reliability for multiline and escaped KaTeX content while keeping malformed delimiters from disrupting message rendering. [#23526](https://github.com/open-webui/open-webui/issues/23526), [Commit](https://github.com/open-webui/open-webui/commit/15b89b9218b7d2c7239c579aa3d23c2892227ac6)
- 🚫 **LDAP empty-password rejection.** LDAP login now rejects empty or whitespace-only passwords before bind attempts, preventing unauthenticated simple-bind behavior from granting access on permissive LDAP server configurations. [#23633](https://github.com/open-webui/open-webui/pull/23633)
- 🌐 **IPv6 SSRF address blocking.** URL validation now uses standard IP address checks for both IPv4 and IPv6, preventing private, loopback, link-local, reserved, and mapped-address SSRF bypasses through IPv6 hostname resolution. [#23453](https://github.com/open-webui/open-webui/pull/23453)
- 🔒 **API key endpoint restriction bypass.** API key endpoint restrictions are now enforced regardless of whether the key is sent through Authorization headers, cookies, or "x-api-key", preventing bypass through alternate key transport paths. [#23637](https://github.com/open-webui/open-webui/pull/23637)
- 🔐 **Channel sharing permission enforcement.** Channel creation and updates now enforce allowed access grant rules for public sharing, preventing unauthorized wildcard sharing on group channels. [#23638](https://github.com/open-webui/open-webui/pull/23638)
- 🛑 **Socket role invalidation.** Socket sessions now disconnect automatically when a user is demoted or deleted, preventing stale admin privileges from persisting until reconnect. [#23642](https://github.com/open-webui/open-webui/pull/23642)
- 🛂 **Tool server access checks.** Tool listing now correctly awaits server access checks, preventing users from seeing server-backed tools they do not have permission to use. [Commit](https://github.com/open-webui/open-webui/commit/d40f31982be3eed37e55e3f67b1eea9a5dc8c525)
- 🛑 **Task endpoint access control.** Global task listing and direct task stop endpoints are now restricted to administrators, while regular users can stop only their own chat tasks through a scoped chat endpoint. [#23454](https://github.com/open-webui/open-webui/pull/23454)
- 🧱 **Redis cache key isolation.** Tool server and terminal server cache entries now include the Redis key prefix, preventing multiple Open WebUI instances that share one Redis database from overwriting each others cached connection data. [#23649](https://github.com/open-webui/open-webui/pull/23649)
- 🧠 **Client session leak prevention.** Outbound provider requests now use a shared session pool with safer response cleanup and shutdown handling, preventing aiohttp session buildup and reducing memory growth during heavy concurrent API traffic. [#23540](https://github.com/open-webui/open-webui/issues/23540), [Commit](https://github.com/open-webui/open-webui/commit/c47dd7b7717c4186e0f0549ca3c8cb4d9bb38135)
- 🧩 **Tool enum value handling.** Tool schema generation now safely handles enum values as strings, preventing failures when OpenAPI parameters include non-string enum entries. [#23597](https://github.com/open-webui/open-webui/issues/23597), [Commit](https://github.com/open-webui/open-webui/commit/4498e6faf2b1bdd1caa0e2c1c15d90a2790cd721)
- 🧷 **Responses model access control.** The OpenAI-compatible Responses endpoint now enforces per-model permissions, preventing non-admin users from accessing models they are not allowed to use. [#23481](https://github.com/open-webui/open-webui/pull/23481)
- 🛡️ **Collection process endpoint permissions.** Collection processing endpoints now enforce collection ownership checks for web and text processing requests. [Commit](https://github.com/open-webui/open-webui/commit/ba83613ff297bc82db660b5273f04672d744902f), [#23634](https://github.com/open-webui/open-webui/pull/23634)
- 📚 **Knowledge query access enforcement.** Knowledge-base collection queries now block unauthorized enumeration and require read access before returning results. [Commit](https://github.com/open-webui/open-webui/commit/860b90fd17d14ba00674621edd294dee150491d2), [#23635](https://github.com/open-webui/open-webui/pull/23635), [#23452](https://github.com/open-webui/open-webui/pull/23452)
- 🔍 **RAG collection query permissions.** Vector search collection queries now enforce access checks before retrieval results are returned. [Commit](https://github.com/open-webui/open-webui/commit/f44b7a01f5b854f47c1594a1ab5f72096f736262), [#23627](https://github.com/open-webui/open-webui/pull/23627)
- 🔗 **Chained base model access checks.** Chained base model execution now enforces per-model access rules to prevent unauthorized model usage. [Commit](https://github.com/open-webui/open-webui/commit/8acce144f99992b75c25f0e5038b16881ce9f066), [Commit](https://github.com/open-webui/open-webui/commit/50363ba66b19613a2fc0cab6a3f7f724a825135e), [#23647](https://github.com/open-webui/open-webui/pull/23647)
- ✍️ **Collaborative document write checks.** Collaborative document updates now require proper write permission before changes are accepted. [Commit](https://github.com/open-webui/open-webui/commit/638c7ab80216452910bdc59a19eb90e6b7244c6c), [Commit](https://github.com/open-webui/open-webui/commit/3271b013a8b30a882364679dcb40ffc9a89f037e), [#23624](https://github.com/open-webui/open-webui/pull/23624)
- 📥 **Model import ownership validation.** Model import now enforces ownership and access grant checks to prevent unauthorized imports. [Commit](https://github.com/open-webui/open-webui/commit/499129625bf96b2c03a6d057a2f91fdf07fd1c49), [#23628](https://github.com/open-webui/open-webui/pull/23628)
- 🚫 **Inactive member channel access.** Deactivated group members can no longer read or write channel content through direct API calls, so channel permissions now match active membership status. [#23623](https://github.com/open-webui/open-webui/pull/23623)
- 🎛️ **Ollama endpoint model permissions.** Restricted models are now protected on Ollama show, generate, embed, and embeddings endpoints, preventing authenticated users from using private models without read access. [#23631](https://github.com/open-webui/open-webui/pull/23631)
- 🧭 **Azure deployment path validation.** Azure model names are now validated and safely encoded before request URL construction, preventing path traversal attempts from reaching unintended Azure endpoints. [#23629](https://github.com/open-webui/open-webui/pull/23629)
- 👥 **Private channel member list access.** Standard channel member lists now require proper read permission, preventing unauthorized users from enumerating members of private channels by direct API calls. [#23625](https://github.com/open-webui/open-webui/pull/23625)
- 🌀 **Tool server schema recursion safety.** Tool server OpenAPI conversion now handles circular request schema references safely, preventing conversion crashes and ensuring one bad tool server spec does not break the full tool server list. [#23588](https://github.com/open-webui/open-webui/pull/23588), [Commit](https://github.com/open-webui/open-webui/commit/d3df8f1f372411314be9121fbf61d107939fa258)
- 🧱 **Safer file path handling.** File upload, transcription cache, and model download paths now use safer path construction helpers to reduce path parsing risks and improve cross-platform path safety. [Commit](https://github.com/open-webui/open-webui/commit/15f9a8f3f13f112c96cb1b16f88859f65de58346)
- 🧾 **Prompt save error feedback.** Saving prompt edits now shows a clear error toast if the save fails, so failed updates are visible instead of silently failing in the editor flow. [Commit](https://github.com/open-webui/open-webui/commit/36a81ad43b7c0d450079f818a7546eaa517e3d95)
- 🧾 **Tool call JSON rendering.** Tool call arguments and structured results now render as plain formatted JSON blocks instead of markdown code fences, preventing formatting quirks and making tool output easier to read consistently. [Commit](https://github.com/open-webui/open-webui/commit/a7d4c53f3adb80768b67e4a410b486b04a581521)
- 👥 **First-user admin race protection.** Concurrent first-time LDAP or OAuth registrations can no longer create multiple admin accounts, so only the true first account is promoted during initial setup. [#23626](https://github.com/open-webui/open-webui/pull/23626)
- 🔒 **SCIM token checks.** SCIM authentication now compares tokens in a safer way, helping prevent timing-based token guessing attacks. [#23577](https://github.com/open-webui/open-webui/pull/23577)
- 🔒 **Safer file access checks.** HTML file previews now treat missing or non-admin owners as inaccessible, preventing accidental access to files that should not be shown. [Commit](https://github.com/open-webui/open-webui/commit/6acaaea59a50ec26da03e6144017a2fd86241ce9)
- 🖼️ **ComfyUI request hangs.** Concurrent image generation and editing requests to ComfyUI now complete reliably instead of getting stuck when the same user starts multiple requests at once. [#23592](https://github.com/open-webui/open-webui/pull/23592), [#23591](https://github.com/open-webui/open-webui/issues/23591)
- 🧭 **Permission-aware built-in tools.** Built-in tools now consistently respect user feature permissions for memories, web search, image generation, code interpreter, notes, channels, and automations, preventing tools from being exposed to users without access. [Commit](https://github.com/open-webui/open-webui/commit/588b81eedaacbfd7394b707ae1600d9fb729b809..674695918e5e3e1811314ce2a082c5bbb42d76b2)
- 🛑 **Interrupted MCP cleanup stability.** Interrupted MCP tool calls no longer leave runaway cleanup behavior that can drive container CPU usage to 100%, keeping instances stable after cancellations or dropped connections. [#23143](https://github.com/open-webui/open-webui/issues/23143)
- 🚪 **OAuth redirect URI reliability.** OAuth login redirects now use provider client metadata more consistently, preventing incorrect HTTP callback URLs behind reverse proxies and improving sign-in reliability for providers such as Feishu. [#23203](https://github.com/open-webui/open-webui/pull/23203), [#23128](https://github.com/open-webui/open-webui/issues/23128)
- 🌐 **OAuth redirect handling.** OAuth provider token exchange now follows redirects automatically, improving sign-in reliability with identity providers that redirect token endpoint requests. [#23409](https://github.com/open-webui/open-webui/issues/23409), [Commit](https://github.com/open-webui/open-webui/commit/498ff8cdc3dd47000cdc60e5adcf36f4adfbe07d)
- ☁️ **OneDrive picker redirect handling.** OneDrive file picker authentication now uses the current app origin as the redirect URI, improving sign-in reliability when launching the picker from deployed environments. [#23450](https://github.com/open-webui/open-webui/issues/23450), [Commit](https://github.com/open-webui/open-webui/commit/21cc8281323d505d7d084cc496bd433063315c86)
- 🍪 **OAuth session cookie persistence.** OIDC sign-in now correctly sets the "oauth_session_id" cookie, so "system_oauth" connections can forward user OAuth tokens to upstream providers as expected. [#23251](https://github.com/open-webui/open-webui/pull/23251), [#23250](https://github.com/open-webui/open-webui/issues/23250)
- 🔑 **OAuth session cookie handling.** OAuth callback processing no longer fails on undefined cookie expiry data, so OAuth session cookies are stored correctly after sign-in. [#23207](https://github.com/open-webui/open-webui/pull/23207), [#23197](https://github.com/open-webui/open-webui/issues/23197)
- 🔏 **Ollama SSL handling.** Ollama model management and file uploads now respect the configured SSL verification setting, so self-signed certificates work when SSL verification is disabled. [#23503](https://github.com/open-webui/open-webui/issues/23503), [Commit](https://github.com/open-webui/open-webui/commit/e51b661af0e71a24f041428f328fcc6e97a15262)
- 🛡️ **OAuth avatar URL validation.** OAuth sign-in now validates profile picture URLs before fetching them, preventing invalid image links from causing login-time errors. [#23356](https://github.com/open-webui/open-webui/pull/23356)
- 🔑 **User invite token expiry.** New user invite logins now respect the configured "JWT_EXPIRES_IN" setting, so signup tokens expire as expected instead of using the default lifetime. [#23576](https://github.com/open-webui/open-webui/pull/23576)
- 🚪 **Channel access checks.** Channel actions now verify the current user when checking access, improving permission enforcement across channel views and message actions. [Commit](https://github.com/open-webui/open-webui/commit/4632f200a9ac98c915aee412b34e86c3d3c58bb1)
- 📣 **Channel message lookups.** Channel message details and pinning now work more reliably when the sender account is missing, avoiding failures in those views. [Commit](https://github.com/open-webui/open-webui/commit/6acaaea59a50ec26da03e6144017a2fd86241ce9)
- 📌 **Pinned webhook message handling.** Viewing pinned webhook messages now works reliably even when webhook profile data is missing, preventing server errors and frontend crashes in channel pinned message dialogs. [#23414](https://github.com/open-webui/open-webui/pull/23414)
- 🛡️ **Note edit permission enforcement.** Note saving now requires write access instead of read access, preventing unauthorized users from modifying notes while preserving expected collaboration permissions. [Commit](https://github.com/open-webui/open-webui/commit/584a9a0920d8c8c72fc89ccbac83c970b5a4bd4a)
- 🗂️ **Archived chats menu visibility.** The 'Archived Chats' option in the user menu is now shown reliably for all users, so non-admin accounts can consistently access archived conversations. [Commit](https://github.com/open-webui/open-webui/commit/07262fa62c2323fc7948389e5b5b8a5d1b72fade)
- 💾 **Error message persistence.** LLM errors that occur during streaming are now saved to the database even if the connection drops, so users can see what went wrong when they reconnect. [#23231](https://github.com/open-webui/open-webui/pull/23231)
- 🚫 **Missing message completion guard.** Chat completion finalization now skips invalid requests without a message identifier, preventing unnecessary error toasts caused by rare frontend concurrency timing. [#23184](https://github.com/open-webui/open-webui/pull/23184)
- 🧠 **Active message completion accuracy.** Switching chats or refreshing during generation no longer marks the currently streaming assistant message as finished too early, so thinking blocks and action buttons appear at the correct time. [#23171](https://github.com/open-webui/open-webui/issues/23171)
- 📞 **Call overlay visibility.** Incoming call events now open the call overlay and controls reliably, preventing cases where the call interface briefly appeared and then disappeared. [Commit](https://github.com/open-webui/open-webui/commit/ee9db91df02120e1e3651e8881734966b710ad52)
- 💬 **Prompt submission handling.** Chat messages now preserve attached files more reliably when prompts are sent, including queued messages and shared prompt actions. [Commit](https://github.com/open-webui/open-webui/commit/6d6dfbf02c893d72d85d4490cb41f1665b1f9f95)
- 🧾 **Prompt variable form saving.** Prompt variable forms now save reliably without runtime errors or an unresponsive save action, so input values and placeholders work correctly when applying prompt templates with variables. [#23225](https://github.com/open-webui/open-webui/issues/23225), [#23480](https://github.com/open-webui/open-webui/issues/23480)
- 🛟 **Task model fallback safety.** Task routing now handles missing default model entries safely, preventing task execution failures when the previously selected model is no longer available. [#23169](https://github.com/open-webui/open-webui/pull/23169)
- 📊 **Usage statistic preservation.** Follow-up generation no longer overwrites existing token usage fields, so stored usage statistics remain accurate for the main response. [#23152](https://github.com/open-webui/open-webui/issues/23152)
- 📝 **Writing block parsing reliability.** ":::writing" blocks now parse more reliably when headers or extra inline text are present, preventing malformed rendering and duplicate output artifacts. [#23174](https://github.com/open-webui/open-webui/issues/23174)
- 🧾 **Code block line break reliability.** Blank lines in submitted code blocks are now preserved more reliably instead of being collapsed. [Commit](https://github.com/open-webui/open-webui/commit/1be9627dd27ffe75957729a4a0d1682a98684f01), [#20302](https://github.com/open-webui/open-webui/issues/20302), [#23451](https://github.com/open-webui/open-webui/pull/23451)
- ✂️ **Citation spacing cleanup.** When citations are disabled for a model, citation markers and their leftover spacing are now removed together so punctuation and copied text remain cleanly formatted. [#23141](https://github.com/open-webui/open-webui/issues/23141)
- 🧰 **Pipe tool access.** Pipe functions now receive built-in and MCP tools in **tools**, so tools like Web Search and code execution are available when enabled. [#23365](https://github.com/open-webui/open-webui/issues/23365)
- 📚 **Batch file processing database handling.** Batch knowledge file processing now consistently uses the active database session, preventing failures caused by missing database context during file ownership checks and update writes. [#23137](https://github.com/open-webui/open-webui/issues/23137)
- ⚙️ **Default model parameter loading.** The "DEFAULT_MODEL_PARAMS" environment variable is now parsed and applied correctly, so default generation settings are honored reliably without being ignored at startup. [#23223](https://github.com/open-webui/open-webui/pull/23223)
- 🔧 **Web search settings save reliability.** Saving web search configuration now works without server errors, so administrators can update "WEB_FETCH_MAX_CONTENT_LENGTH" and related retrieval settings successfully from the admin interface. [Commit](https://github.com/open-webui/open-webui/commit/36d02aa1477aa1b4e7fb59d022f99693ebfa8667), [#23127](https://github.com/open-webui/open-webui/issues/23127)
- 🔍 **Web search result count.** The built-in search_web tool now respects the admin-configured "Search Result Count" setting instead of always returning 5 results when using Native Function Calling mode. [#23488](https://github.com/open-webui/open-webui/pull/23488), [#23485](https://github.com/open-webui/open-webui/issues/23485)
- 🖼️ **Open Terminal file response handling.** Open Terminal tool responses now preserve binary content types in user-side connections, so image and non-text file reads work consistently instead of being forced into plain text. [#23125](https://github.com/open-webui/open-webui/issues/23125)
- 🖥️ **Terminal label casing.** Terminal names in the chat input now display exactly as stored instead of being automatically capitalized, so domain-style server names appear correctly. [#23518](https://github.com/open-webui/open-webui/pull/23518)
- 🖼️ **Gravatar profile photo saving.** Gravatar profile images can now be saved successfully from account settings, with clearer validation and error handling instead of failing with generic object errors. [#23156](https://github.com/open-webui/open-webui/issues/23156)
- 🪟 **Details expansion preference.** Tool call detail groups now honor the 'Always Expand Details' chat setting, so they open expanded by default when that preference is enabled. [#23262](https://github.com/open-webui/open-webui/pull/23262), [#23255](https://github.com/open-webui/open-webui/issues/23255)
- 🖱️ **Rapid sidebar action protection.** Archive and delete actions in the chat sidebar now ignore repeated clicks while a request is in progress, preventing duplicate requests and stacked error toasts. [#23172](https://github.com/open-webui/open-webui/issues/23172)
- 📲 **Mobile model selector positioning.** The mobile model selector dropdown now applies a constrained viewport width and left offset, preventing overflow and making model selection easier on small screens. [#23310](https://github.com/open-webui/open-webui/pull/23310)
- 🔽 **Task list toggle icons.** The task list collapse button now shows the correct arrow direction, making task sections easier to expand and collapse at a glance. [Commit](https://github.com/open-webui/open-webui/commit/f66b67c8b86b6f9d896a23c7bb53907c2e6b15d3), [#23354](https://github.com/open-webui/open-webui/issues/23354)
- **Attachment menu auto-close.** The chat attachment menu now closes immediately after selecting upload actions like file upload, camera capture, web attach, Google Drive, or OneDrive, preventing the menu from lingering on screen. [Commit](https://github.com/open-webui/open-webui/commit/4764dd5d3765c22384ed38cbc97a8170daa7a75f), [#23320](https://github.com/open-webui/open-webui/issues/23320)
- 🧹 **Per-chat draft clearing.** Sent message drafts are now cleared using the active chat key, so sent text no longer reappears in the input after a refresh. [Commit](https://github.com/open-webui/open-webui/commit/124b7e9154d7f3ca8a16f2b90621209ac8d6b8c1), [#23296](https://github.com/open-webui/open-webui/issues/23296)
- ✉️ **Context-aware input action button.** The input now shows the send action when text or files are present during generation, while keeping stop controls for truly empty input states to avoid action confusion. [Commit](https://github.com/open-webui/open-webui/commit/86472bb4453af7ea4e5ddc8d127b14d8e67733bc), [#23306](https://github.com/open-webui/open-webui/issues/23306)
- 📉 **Pyodide prompt cache stability.** Pyodide code interpreter context is now appended to the system prompt instead of user messages, preserving stable prefix caching across turns and reducing repeated token costs in long native tool-calling chats. [#23269](https://github.com/open-webui/open-webui/issues/23269)
- 🧪 **Temp chat outlet filtering.** Outlet filters now process temporary chats more reliably, preserving assistant output and usage data so local chat responses stay consistent when filter pipelines are enabled. [Commit](https://github.com/open-webui/open-webui/commit/70a6a24f143b221c787bc50b72582ee1e0c2dac0)
### Changed
- ⚠️ **Database Migrations**: This release includes database schema changes; we strongly recommend backing up your database and all associated data before upgrading in production environments. If you are running a multi-worker, multi-server, or load-balanced deployment, all instances must be updated simultaneously, rolling updates are not supported and will cause application failures due to schema incompatibility.
- 🧨 **Plugin async migration required.** Custom plugins for Tools, Functions, and Pipelines may require migration to the new async backend signatures after upgrading, so plugin maintainers should update handlers and database call patterns for compatibility and follow the 0.9.0 plugin migration guide. [Migration Guide](https://docs.openwebui.com/features/extensibility/plugin/migration/to-0.9.0)
- 🔄 **Automation terminal source.** Automations now use the terminal configured on the selected model instead of a separate per-automation terminal picker, keeping terminal behavior consistent between chat and scheduled runs. [Commit](https://github.com/open-webui/open-webui/commit/47d413ce7b2a006a8126f4a9055b13e5fcb33a1d)
- 🚧 **OpenAI passthrough now opt-in.** Direct OpenAI catch-all proxy requests are now disabled by default and require enabling "ENABLE_OPENAI_API_PASSTHROUGH", so deployments relying on passthrough must explicitly turn it on after upgrading. [#23640](https://github.com/open-webui/open-webui/pull/23640)
- 🗄️ **SQLite WAL default enabled.** SQLite deployments now default to enabling write-ahead logging, improving concurrent read and write behavior without requiring manual configuration. [Commit](https://github.com/open-webui/open-webui/commit/2f9e326dba3b1087932cb6b8075ed1881bd1c6d6)
## [0.8.12] - 2026-03-26
### Added

View file

@ -1524,6 +1524,8 @@ USER_PERMISSIONS_FEATURES_AUTOMATIONS = (
os.environ.get('USER_PERMISSIONS_FEATURES_AUTOMATIONS', 'False').lower() == 'true'
)
USER_PERMISSIONS_FEATURES_CALENDAR = os.environ.get('USER_PERMISSIONS_FEATURES_CALENDAR', 'True').lower() == 'true'
USER_PERMISSIONS_SETTINGS_INTERFACE = os.environ.get('USER_PERMISSIONS_SETTINGS_INTERFACE', 'True').lower() == 'true'
@ -1594,6 +1596,7 @@ DEFAULT_USER_PERMISSIONS = {
'code_interpreter': USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
'memories': USER_PERMISSIONS_FEATURES_MEMORIES,
'automations': USER_PERMISSIONS_FEATURES_AUTOMATIONS,
'calendar': USER_PERMISSIONS_FEATURES_CALENDAR,
},
'settings': {
'interface': USER_PERMISSIONS_SETTINGS_INTERFACE,
@ -1624,6 +1627,18 @@ ENABLE_CHANNELS = PersistentConfig(
os.environ.get('ENABLE_CHANNELS', 'False').lower() == 'true',
)
ENABLE_CALENDAR = PersistentConfig(
'ENABLE_CALENDAR',
'calendar.enable',
os.environ.get('ENABLE_CALENDAR', 'True').lower() == 'true',
)
ENABLE_AUTOMATIONS = PersistentConfig(
'ENABLE_AUTOMATIONS',
'automations.enable',
os.environ.get('ENABLE_AUTOMATIONS', 'True').lower() == 'true',
)
AUTOMATION_MAX_COUNT = PersistentConfig(
'AUTOMATION_MAX_COUNT',
'automations.max_count',
@ -2977,6 +2992,12 @@ RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = (
os.environ.get('RAG_RERANKING_MODEL_TRUST_REMOTE_CODE', 'True').lower() == 'true'
)
RAG_RERANKING_BATCH_SIZE = PersistentConfig(
'RAG_RERANKING_BATCH_SIZE',
'rag.reranking_batch_size',
int(os.environ.get('RAG_RERANKING_BATCH_SIZE', '32')),
)
RAG_EXTERNAL_RERANKER_URL = PersistentConfig(
'RAG_EXTERNAL_RERANKER_URL',
'rag.external_reranker_url',

View file

@ -375,7 +375,35 @@ else:
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', 'True').lower() == 'true'
# SQLite PRAGMA tuning — these defaults are optimised for WAL-mode web-server
# workloads. Each can be overridden via its environment variable.
# Set any value to an empty string to skip that PRAGMA entirely.
# PRAGMA synchronous: NORMAL (1) is safe with WAL and avoids an fsync per
# transaction. Valid values: OFF (0), NORMAL (1), FULL (2), EXTRA (3).
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS = os.environ.get('DATABASE_SQLITE_PRAGMA_SYNCHRONOUS', 'NORMAL')
# PRAGMA busy_timeout (ms): how long a connection waits for a write lock
# before raising SQLITE_BUSY.
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT = os.environ.get('DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT', '5000')
# PRAGMA cache_size: negative value = KiB. -65536 ≈ 64 MB page cache.
DATABASE_SQLITE_PRAGMA_CACHE_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_CACHE_SIZE', '-65536')
# PRAGMA temp_store: MEMORY (2) keeps temp tables and indices in RAM.
# Valid values: DEFAULT (0), FILE (1), MEMORY (2).
DATABASE_SQLITE_PRAGMA_TEMP_STORE = os.environ.get('DATABASE_SQLITE_PRAGMA_TEMP_STORE', 'MEMORY')
# PRAGMA mmap_size (bytes): memory-mapped I/O size. 268435456 ≈ 256 MB.
# Set to 0 to disable mmap.
DATABASE_SQLITE_PRAGMA_MMAP_SIZE = os.environ.get('DATABASE_SQLITE_PRAGMA_MMAP_SIZE', '268435456')
# PRAGMA journal_size_limit (bytes): caps the WAL file size after checkpoint.
# Without this the WAL grows unbounded during write bursts and is never
# truncated. 67108864 ≈ 64 MB. Set to -1 for no limit (SQLite default).
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT = os.environ.get('DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT', '67108864')
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:
@ -646,6 +674,15 @@ ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION = (
os.environ.get('ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION', 'False').lower() == 'true'
)
# When enabled, uses a hardcoded extension-to-MIME dictionary as a last-resort
# fallback when both mimetypes.guess_type() and file.meta.content_type fail to
# determine the content type. This can help on minimal container images (e.g.
# wolfi-base) that lack /etc/mime.types AND have legacy files without stored
# content_type metadata.
ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK = (
os.environ.get('ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK', 'False').lower() == 'true'
)
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE = os.environ.get('CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE', '1')
if CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE == '':

View file

@ -34,8 +34,10 @@ from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.access_control import check_model_access
from open_webui.env import GLOBAL_LOG_LEVEL
from open_webui.env import GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.utils.misc import (
add_or_update_system_message,
@ -232,11 +234,24 @@ async def generate_function_chat_completion(request, form_data, user, models: di
oauth_token = None
try:
if request.cookies.get('oauth_session_id', None):
oauth_session_id = request.cookies.get('oauth_session_id', None)
if oauth_session_id:
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get('oauth_session_id', None),
oauth_session_id,
)
# Fallback: no cookie (automation, API key, etc.) — use most recent session
if oauth_token is None:
from open_webui.models.oauth_sessions import OAuthSessions
sessions = await OAuthSessions.get_sessions_by_user_id(user.id)
if sessions:
best = max(sessions, key=lambda s: s.updated_at)
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
best.id,
)
except Exception as e:
log.error(f'Error getting OAuth token: {e}')
@ -260,6 +275,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
if model_info.base_model_id:
form_data['model'] = model_info.base_model_id
if not BYPASS_MODEL_ACCESS_CONTROL:
bypass = isinstance(user, UserModel) and user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL
await check_model_access(user if isinstance(user, UserModel) else UserModel(**user), model_info, bypass)
params = model_info.params.model_dump()
if params:

View file

@ -1,8 +1,10 @@
import os
import json
import logging
import ssl as _stdlib_ssl
from contextlib import asynccontextmanager, contextmanager
from typing import Any, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from open_webui.internal.wrappers import register_connection
from open_webui.env import (
@ -15,6 +17,12 @@ from open_webui.env import (
DATABASE_POOL_TIMEOUT,
DATABASE_ENABLE_SQLITE_WAL,
DATABASE_ENABLE_SESSION_SHARING,
DATABASE_SQLITE_PRAGMA_SYNCHRONOUS,
DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT,
DATABASE_SQLITE_PRAGMA_CACHE_SIZE,
DATABASE_SQLITE_PRAGMA_TEMP_STORE,
DATABASE_SQLITE_PRAGMA_MMAP_SIZE,
DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT,
ENABLE_DB_MIGRATIONS,
)
from peewee_migrate import Router
@ -29,6 +37,92 @@ from typing_extensions import Self
log = logging.getLogger(__name__)
def extract_ssl_mode_from_url(url: str) -> tuple[str, str | None]:
"""Strip SSL query-string parameters from a PostgreSQL URL.
asyncpg and psycopg2 use different query-string keys for SSL
(``ssl`` vs ``sslmode``). This helper removes **both** from the
URL so that each driver can receive the correct parameter through
its own mechanism (query-string re-injection for psycopg2,
``connect_args`` for asyncpg).
Returns
-------
(url_without_ssl, ssl_mode)
*url_without_ssl* is the original URL with ``ssl`` / ``sslmode``
query parameters removed. *ssl_mode* is the extracted mode
string (e.g. ``'require'``), or ``None`` if neither parameter
was present.
Non-PostgreSQL URLs are returned unchanged with ``ssl_mode=None``.
"""
if not url or not any(url.startswith(prefix) for prefix in ('postgresql://', 'postgresql+', 'postgres://')):
return url, None
parsed = urlparse(url)
query_params = parse_qs(parsed.query, keep_blank_values=True)
# Prefer sslmode (libpq canonical) over the asyncpg-only ssl key.
ssl_mode: str | None = None
for key in ('sslmode', 'ssl'):
values = query_params.pop(key, None)
if values and ssl_mode is None:
ssl_mode = values[0]
if ssl_mode is None:
# Nothing to strip — return the URL untouched.
return url, None
# Rebuild the query string without the SSL keys.
remaining_query = urlencode(query_params, doseq=True)
url_without_ssl = urlunparse(parsed._replace(query=remaining_query))
return url_without_ssl, ssl_mode
def build_asyncpg_ssl_args(ssl_mode: str | None) -> dict:
"""Convert a libpq-style SSL mode value to asyncpg ``connect_args``.
Returns a dict suitable for unpacking into
``create_async_engine(..., connect_args=...)``.
"""
if ssl_mode is None:
return {}
mode = ssl_mode.lower()
if mode == 'disable':
return {'connect_args': {'ssl': False}}
if mode in ('allow', 'prefer'):
# asyncpg has no direct equivalent — omit to let it try without.
return {}
if mode == 'require':
# SSL required but no certificate verification (matches libpq).
ctx = _stdlib_ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _stdlib_ssl.CERT_NONE
return {'connect_args': {'ssl': ctx}}
if mode in ('verify-ca', 'verify-full'):
# Full verification — use the system trust store.
ctx = _stdlib_ssl.create_default_context()
if mode == 'verify-ca':
ctx.check_hostname = False
return {'connect_args': {'ssl': ctx}}
# Unknown value — pass through as-is and let asyncpg decide.
return {'connect_args': {'ssl': ssl_mode}}
def reattach_ssl_mode_to_url(url_without_ssl: str, ssl_mode: str | None) -> str:
"""Re-append ``sslmode=<value>`` to a cleaned PostgreSQL URL.
Used for psycopg2 / libpq consumers that expect the canonical
``sslmode`` query-string key.
"""
if ssl_mode is None:
return url_without_ssl
separator = '&' if '?' in url_without_ssl else '?'
return f'{url_without_ssl}{separator}sslmode={ssl_mode}'
class JSONField(types.TypeDecorator):
impl = types.Text
cache_ok = True
@ -54,10 +148,14 @@ class JSONField(types.TypeDecorator):
# Workaround to handle the peewee migration
# This is required to ensure the peewee migration is handled before the alembic migration
def handle_peewee_migration(DATABASE_URL):
# db = None
db = None
try:
# Normalize SSL params so psycopg2 always sees `sslmode=` (never `ssl=`).
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DATABASE_URL)
normalized_url = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode)
# Replace the postgresql:// with postgres:// to handle the peewee migration
db = register_connection(DATABASE_URL.replace('postgresql://', 'postgres://'))
db = register_connection(normalized_url.replace('postgresql://', 'postgres://'))
migrate_dir = OPEN_WEBUI_DIR / 'internal' / 'migrations'
router = Router(db, logger=log, migrate_dir=migrate_dir)
router.run()
@ -73,14 +171,22 @@ def handle_peewee_migration(DATABASE_URL):
db.close()
# Assert if db connection has been closed
assert db.is_closed(), 'Database connection is still open.'
if db is not None:
assert db.is_closed(), 'Database connection is still open.'
if ENABLE_DB_MIGRATIONS:
handle_peewee_migration(DATABASE_URL)
SQLALCHEMY_DATABASE_URL = DATABASE_URL
# Normalize SSL params from the URL once; each engine branch re-injects
# the driver-appropriate form.
DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE = extract_ssl_mode_from_url(DATABASE_URL)
# For psycopg2 (sync engine), re-append sslmode=<value>.
SQLALCHEMY_DATABASE_URL = (
reattach_ssl_mode_to_url(DATABASE_URL_WITHOUT_SSL, DATABASE_SSL_MODE) if DATABASE_SSL_MODE else DATABASE_URL
)
def _make_async_url(url: str) -> str:
@ -155,14 +261,32 @@ if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
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):
def _apply_sqlite_pragmas(dbapi_connection):
"""Apply all configured SQLite PRAGMAs to a raw DBAPI connection."""
cursor = dbapi_connection.cursor()
if DATABASE_ENABLE_SQLITE_WAL:
cursor.execute('PRAGMA journal_mode=WAL')
else:
cursor.execute('PRAGMA journal_mode=DELETE')
# Each PRAGMA is skipped when its env var is empty, allowing opt-out.
if DATABASE_SQLITE_PRAGMA_SYNCHRONOUS:
cursor.execute(f'PRAGMA synchronous={DATABASE_SQLITE_PRAGMA_SYNCHRONOUS}')
if DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT:
cursor.execute(f'PRAGMA busy_timeout={DATABASE_SQLITE_PRAGMA_BUSY_TIMEOUT}')
if DATABASE_SQLITE_PRAGMA_CACHE_SIZE:
cursor.execute(f'PRAGMA cache_size={DATABASE_SQLITE_PRAGMA_CACHE_SIZE}')
if DATABASE_SQLITE_PRAGMA_TEMP_STORE:
cursor.execute(f'PRAGMA temp_store={DATABASE_SQLITE_PRAGMA_TEMP_STORE}')
if DATABASE_SQLITE_PRAGMA_MMAP_SIZE:
cursor.execute(f'PRAGMA mmap_size={DATABASE_SQLITE_PRAGMA_MMAP_SIZE}')
if DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT:
cursor.execute(f'PRAGMA journal_size_limit={DATABASE_SQLITE_PRAGMA_JOURNAL_SIZE_LIMIT}')
cursor.close()
def on_connect(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
event.listen(engine, 'connect', on_connect)
else:
if isinstance(DATABASE_POOL_SIZE, int):
@ -205,22 +329,31 @@ get_db = contextmanager(get_session)
# ASYNC ENGINE (used for ALL runtime database operations)
# ============================================================
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
# Use the SSL-stripped URL for asyncpg — SSL is injected via connect_args.
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(
DATABASE_URL_WITHOUT_SSL if DATABASE_SSL_MODE else SQLALCHEMY_DATABASE_URL
)
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
# Generous default — async coroutines + no session sharing = high connection demand.
_sqlite_pool_size = DATABASE_POOL_SIZE if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0 else 512
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
connect_args={'check_same_thread': False},
pool_size=_sqlite_pool_size,
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
)
if DATABASE_ENABLE_SQLITE_WAL:
@event.listens_for(async_engine.sync_engine, 'connect')
def _set_sqlite_wal(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute('PRAGMA journal_mode=WAL')
cursor.close()
@event.listens_for(async_engine.sync_engine, 'connect')
def _set_sqlite_pragmas(dbapi_connection, connection_record):
_apply_sqlite_pragmas(dbapi_connection)
else:
# Inject asyncpg-compatible SSL connect_args when the user specified
# sslmode/ssl in DATABASE_URL.
asyncpg_ssl_args = build_asyncpg_ssl_args(DATABASE_SSL_MODE)
if isinstance(DATABASE_POOL_SIZE, int):
if DATABASE_POOL_SIZE > 0:
async_engine = create_async_engine(
@ -230,17 +363,20 @@ else:
pool_timeout=DATABASE_POOL_TIMEOUT,
pool_recycle=DATABASE_POOL_RECYCLE,
pool_pre_ping=True,
**asyncpg_ssl_args,
)
else:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
poolclass=NullPool,
**asyncpg_ssl_args,
)
else:
async_engine = create_async_engine(
ASYNC_SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
**asyncpg_ssl_args,
)

View file

@ -105,6 +105,7 @@ from open_webui.routers import (
scim,
terminals,
automations,
calendar,
)
from open_webui.routers.retrieval import (
@ -248,6 +249,7 @@ from open_webui.config import (
RAG_EXTERNAL_RERANKER_URL,
RAG_EXTERNAL_RERANKER_API_KEY,
RAG_EXTERNAL_RERANKER_TIMEOUT,
RAG_RERANKING_BATCH_SIZE,
RAG_RERANKING_MODEL_AUTO_UPDATE,
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
RAG_EMBEDDING_ENGINE,
@ -393,9 +395,11 @@ from open_webui.config import (
API_KEYS_ALLOWED_ENDPOINTS,
ENABLE_FOLDERS,
FOLDER_MAX_FILE_COUNT,
ENABLE_AUTOMATIONS,
AUTOMATION_MAX_COUNT,
AUTOMATION_MIN_INTERVAL,
ENABLE_CHANNELS,
ENABLE_CALENDAR,
ENABLE_NOTES,
ENABLE_USER_STATUS,
ENABLE_COMMUNITY_SHARING,
@ -672,9 +676,9 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_usage_pool_cleanup())
asyncio.create_task(periodic_session_pool_cleanup())
from open_webui.utils.automations import automation_worker_loop
from open_webui.utils.automations import scheduler_worker_loop
asyncio.create_task(automation_worker_loop(app))
asyncio.create_task(scheduler_worker_loop(app))
if app.state.config.ENABLE_BASE_MODELS_CACHE:
try:
@ -899,9 +903,11 @@ app.state.config.BANNERS = WEBUI_BANNERS
app.state.config.ENABLE_FOLDERS = ENABLE_FOLDERS
app.state.config.FOLDER_MAX_FILE_COUNT = FOLDER_MAX_FILE_COUNT
app.state.config.ENABLE_AUTOMATIONS = ENABLE_AUTOMATIONS
app.state.config.AUTOMATION_MAX_COUNT = AUTOMATION_MAX_COUNT
app.state.config.AUTOMATION_MIN_INTERVAL = AUTOMATION_MIN_INTERVAL
app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
app.state.config.ENABLE_CALENDAR = ENABLE_CALENDAR
app.state.config.ENABLE_NOTES = ENABLE_NOTES
app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
@ -1048,6 +1054,7 @@ app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL
app.state.config.RAG_EXTERNAL_RERANKER_URL = RAG_EXTERNAL_RERANKER_URL
app.state.config.RAG_EXTERNAL_RERANKER_API_KEY = RAG_EXTERNAL_RERANKER_API_KEY
app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT = RAG_EXTERNAL_RERANKER_TIMEOUT
app.state.config.RAG_RERANKING_BATCH_SIZE = RAG_RERANKING_BATCH_SIZE
app.state.config.RAG_TEMPLATE = RAG_TEMPLATE
@ -1197,6 +1204,7 @@ app.state.RERANKING_FUNCTION = get_reranking_function(
app.state.config.RAG_RERANKING_ENGINE,
app.state.config.RAG_RERANKING_MODEL,
reranking_function=app.state.rf,
reranking_batch_size=app.state.config.RAG_RERANKING_BATCH_SIZE,
)
########################################
@ -1434,6 +1442,7 @@ if ENABLE_ADMIN_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(automations.router, prefix='/api/v1/automations', tags=['automations'])
app.include_router(calendar.router, prefix='/api/v1/calendars', tags=['calendars'])
# SCIM 2.0 API for identity management
if ENABLE_SCIM:
@ -1824,6 +1833,8 @@ async def chat_completion(
request.state.metadata = metadata
form_data['metadata'] = metadata
except HTTPException:
raise
except Exception as e:
log.debug(f'Error processing chat metadata: {e}')
raise HTTPException(
@ -1870,7 +1881,8 @@ async def chat_completion(
finally:
raise # re-raise to ensure proper task cancellation handling
except Exception as e:
log.error('Error processing chat payload: %s', e)
error_detail = e.detail if isinstance(e, HTTPException) else str(e)
log.error('Error processing chat payload: %s', error_detail)
if metadata.get('chat_id') and metadata.get('message_id'):
# Update the chat message with the error
try:
@ -1880,7 +1892,7 @@ async def chat_completion(
metadata['message_id'],
{
'parentId': metadata.get('user_message_id', None),
'error': {'content': str(e)},
'error': {'content': error_detail},
},
)
@ -1889,7 +1901,7 @@ async def chat_completion(
await event_emitter(
{
'type': 'chat:message:error',
'data': {'error': {'content': str(e)}},
'data': {'error': {'content': error_detail}},
}
)
await event_emitter(
@ -2215,6 +2227,8 @@ async def get_app_config(request: Request):
'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_calendar': app.state.config.ENABLE_CALENDAR,
'enable_automations': app.state.config.ENABLE_AUTOMATIONS,
'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,

View file

@ -3,7 +3,9 @@ from logging.config import fileConfig
from alembic import context
from open_webui.models.auths import Auth
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
from open_webui.env import DATABASE_URL, DATABASE_PASSWORD, LOG_FORMAT
from open_webui.internal.db import extract_ssl_mode_from_url, reattach_ssl_mode_to_url
from sqlalchemy import engine_from_config, pool, create_engine
# this is the Alembic Config object, which provides
@ -35,6 +37,10 @@ target_metadata = Auth.metadata
DB_URL = DATABASE_URL
# Normalize SSL query params for psycopg2 (Alembic uses psycopg2, not asyncpg).
url_without_ssl, ssl_mode = extract_ssl_mode_from_url(DB_URL)
DB_URL = reattach_ssl_mode_to_url(url_without_ssl, ssl_mode) if ssl_mode else DB_URL
if DB_URL:
config.set_main_option('sqlalchemy.url', DB_URL.replace('%', '%%'))

View file

@ -0,0 +1,83 @@
"""add calendar tables
Revision ID: 56359461a091
Revises: c1d2e3f4a5b6
Create Date: 2026-04-19 16:20:58.162045
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '56359461a091'
down_revision: Union[str, None] = 'c1d2e3f4a5b6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'calendar',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=False),
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=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_user', 'calendar', ['user_id'], unique=False)
op.create_table(
'calendar_event',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('calendar_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('start_at', sa.BigInteger(), nullable=False),
sa.Column('end_at', sa.BigInteger(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('rrule', sa.Text(), nullable=True),
sa.Column('color', sa.Text(), nullable=True),
sa.Column('location', sa.Text(), nullable=True),
sa.Column('data', sa.JSON(), nullable=True),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_cancelled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_calendar_event_calendar', 'calendar_event', ['calendar_id', 'start_at'], unique=False)
op.create_index('ix_calendar_event_user_date', 'calendar_event', ['user_id', 'start_at'], unique=False)
op.create_table(
'calendar_event_attendee',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('event_id', sa.Text(), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
)
op.create_index('ix_calendar_event_attendee_user', 'calendar_event_attendee', ['user_id', 'status'], unique=False)
def downgrade() -> None:
op.drop_index('ix_calendar_event_attendee_user', table_name='calendar_event_attendee')
op.drop_table('calendar_event_attendee')
op.drop_index('ix_calendar_event_user_date', table_name='calendar_event')
op.drop_index('ix_calendar_event_calendar', table_name='calendar_event')
op.drop_table('calendar_event')
op.drop_index('ix_calendar_user', table_name='calendar')
op.drop_table('calendar')

View file

@ -0,0 +1,164 @@
"""Add shared_chat table and migrate existing shares
Revision ID: c1d2e3f4a5b6
Revises: e1f2a3b4c5d6
Create Date: 2026-04-16 23:00:00.000000
"""
import time
import uuid
from alembic import op
import sqlalchemy as sa
revision = 'c1d2e3f4a5b6'
down_revision = 'e1f2a3b4c5d6'
branch_labels = None
depends_on = None
# Lightweight table references for data migration (no ORM models needed)
chat_t = sa.table(
'chat',
sa.column('id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('title', sa.Text),
sa.column('chat', sa.JSON),
sa.column('share_id', sa.Text),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
sa.column('archived', sa.Boolean),
sa.column('meta', sa.JSON),
)
shared_chat_t = sa.table(
'shared_chat',
sa.column('id', sa.Text),
sa.column('chat_id', sa.Text),
sa.column('user_id', sa.Text),
sa.column('title', sa.Text),
sa.column('chat', sa.JSON),
sa.column('created_at', sa.BigInteger),
sa.column('updated_at', sa.BigInteger),
)
chat_message_t = sa.table(
'chat_message',
sa.column('chat_id', sa.Text),
)
access_grant_t = sa.table(
'access_grant',
sa.column('id', sa.Text),
sa.column('resource_type', sa.Text),
sa.column('resource_id', sa.Text),
sa.column('principal_type', sa.Text),
sa.column('principal_id', sa.Text),
sa.column('permission', sa.Text),
sa.column('created_at', sa.BigInteger),
)
def upgrade():
conn = op.get_bind()
# 1. Create shared_chat table
op.create_table(
'shared_chat',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('chat_id', sa.Text(), sa.ForeignKey('chat.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('title', sa.Text(), nullable=True),
sa.Column('chat', sa.JSON(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=True),
sa.Column('updated_at', sa.BigInteger(), nullable=True),
)
# 2. Migrate existing shared-* rows
shared_rows = conn.execute(
sa.select(
chat_t.c.id,
chat_t.c.user_id,
chat_t.c.title,
chat_t.c.chat,
chat_t.c.created_at,
chat_t.c.updated_at,
).where(chat_t.c.user_id.like('shared-%'))
).fetchall()
for row in shared_rows:
share_token = row.id
original_chat_id = row.user_id.replace('shared-', '', 1)
# Verify original chat still exists
original = conn.execute(sa.select(chat_t.c.user_id).where(chat_t.c.id == original_chat_id)).fetchone()
if not original:
continue
# Insert snapshot into shared_chat
conn.execute(
shared_chat_t.insert().values(
id=share_token,
chat_id=original_chat_id,
user_id=original.user_id,
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
# Create user:*:read grant for backward compat
conn.execute(
access_grant_t.insert().values(
id=str(uuid.uuid4()),
resource_type='shared_chat',
resource_id=original_chat_id,
principal_type='user',
principal_id='*',
permission='read',
created_at=row.created_at or int(time.time()),
)
)
# 3. Clean up old phantom rows
conn.execute(
chat_message_t.delete().where(
chat_message_t.c.chat_id.in_(sa.select(chat_t.c.id).where(chat_t.c.user_id.like('shared-%')))
)
)
conn.execute(chat_t.delete().where(chat_t.c.user_id.like('shared-%')))
def downgrade():
conn = op.get_bind()
shared_rows = conn.execute(
sa.select(
shared_chat_t.c.id,
shared_chat_t.c.chat_id,
shared_chat_t.c.user_id,
shared_chat_t.c.title,
shared_chat_t.c.chat,
shared_chat_t.c.created_at,
shared_chat_t.c.updated_at,
)
).fetchall()
for row in shared_rows:
conn.execute(
chat_t.insert().values(
id=row.id,
user_id=f'shared-{row.chat_id}',
title=row.title,
chat=row.chat,
created_at=row.created_at,
updated_at=row.updated_at,
archived=False,
meta={},
)
)
conn.execute(access_grant_t.delete().where(access_grant_t.c.resource_type == 'shared_chat'))
op.drop_table('shared_chat')

View file

@ -153,6 +153,14 @@ class AutomationTable:
row = await db.get(Automation, id)
return AutomationModel.model_validate(row) if row else None
async def get_active_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[AutomationModel]:
"""Get active automations for a user (for calendar RRULE expansion)."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(Automation).filter_by(user_id=user_id, is_active=True).order_by(Automation.created_at.desc())
)
return [AutomationModel.model_validate(r) for r in result.scalars().all()]
async def search_automations(
self,
user_id: str,
@ -273,9 +281,19 @@ class AutomationTable:
from open_webui.utils.automations import next_run_ns
# Batch-fetch user timezones so rescheduling respects each
# user's local timezone instead of falling back to server time.
user_ids = list({row.user_id for row in rows})
timezone_by_user_id: dict[str, Optional[str]] = {}
if user_ids:
from open_webui.models.users import User
tz_result = await db.execute(select(User.id, User.timezone).where(User.id.in_(user_ids)))
timezone_by_user_id = {uid: tz for uid, tz in tz_result.all()}
for row in rows:
row.last_run_at = now_ns
row.next_run_at = next_run_ns(row.data.get('rrule', ''))
row.next_run_at = next_run_ns(row.data.get('rrule', ''), tz=timezone_by_user_id.get(row.user_id))
await db.commit()
@ -372,6 +390,32 @@ class AutomationRunTable:
await db.commit()
return result.rowcount
async def get_runs_by_user_range(
self,
user_id: str,
start_ns: int,
end_ns: int,
limit: int = 500,
db: Optional[AsyncSession] = None,
) -> list[tuple['AutomationRunModel', 'AutomationModel']]:
"""Get runs within a date range for a user, joined with parent automation."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(AutomationRun, Automation)
.join(Automation, Automation.id == AutomationRun.automation_id)
.filter(
Automation.user_id == user_id,
AutomationRun.created_at >= start_ns,
AutomationRun.created_at < end_ns,
)
.order_by(AutomationRun.created_at.desc())
.limit(limit)
)
return [
(AutomationRunModel.model_validate(run), AutomationModel.model_validate(auto))
for run, auto in result.all()
]
Automations = AutomationTable()
AutomationRuns = AutomationRunTable()

View file

@ -0,0 +1,822 @@
import time
import logging
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import (
Column,
Text,
JSON,
Boolean,
BigInteger,
Index,
UniqueConstraint,
select,
or_,
exists,
func,
delete,
update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse
log = logging.getLogger(__name__)
####################
# Calendar DB Schema
####################
class Calendar(Base):
__tablename__ = 'calendar'
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
name = Column(Text, nullable=False)
color = Column(Text, nullable=True)
is_default = Column(Boolean, nullable=False, default=False)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (Index('ix_calendar_user', 'user_id'),)
class CalendarEvent(Base):
__tablename__ = 'calendar_event'
id = Column(Text, primary_key=True)
calendar_id = Column(Text, nullable=False)
user_id = Column(Text, nullable=False)
title = Column(Text, nullable=False)
description = Column(Text, nullable=True)
start_at = Column(BigInteger, nullable=False)
end_at = Column(BigInteger, nullable=True)
all_day = Column(Boolean, nullable=False, default=False)
rrule = Column(Text, nullable=True)
color = Column(Text, nullable=True)
location = Column(Text, nullable=True)
data = Column(JSON, nullable=True)
meta = Column(JSON, nullable=True)
is_cancelled = Column(Boolean, nullable=False, default=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
Index('ix_calendar_event_calendar', 'calendar_id', 'start_at'),
Index('ix_calendar_event_user_date', 'user_id', 'start_at'),
)
class CalendarEventAttendee(Base):
__tablename__ = 'calendar_event_attendee'
id = Column(Text, primary_key=True)
event_id = Column(Text, nullable=False)
user_id = Column(Text, nullable=False)
status = Column(Text, nullable=False, default='pending')
meta = Column(JSON, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
Index('ix_calendar_event_attendee_user', 'user_id', 'status'),
)
####################
# Pydantic Models
####################
class CalendarModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
name: str
color: Optional[str] = None
is_default: bool = False
is_system: bool = False
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: list[AccessGrantModel] = Field(default_factory=list)
created_at: int
updated_at: int
class CalendarEventModel(BaseModel):
model_config = ConfigDict(from_attributes=True, extra='allow')
id: str
calendar_id: str
user_id: str
title: str
description: Optional[str] = None
start_at: int
end_at: Optional[int] = None
all_day: bool = False
rrule: Optional[str] = None
color: Optional[str] = None
location: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
is_cancelled: bool = False
attendees: list['CalendarEventAttendeeModel'] = Field(default_factory=list)
created_at: int
updated_at: int
class CalendarEventAttendeeModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
event_id: str
user_id: str
status: str = 'pending'
meta: Optional[dict] = None
created_at: int
updated_at: int
####################
# Forms
####################
class CalendarForm(BaseModel):
name: str
color: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class CalendarUpdateForm(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
access_grants: Optional[list[dict]] = None
class CalendarEventForm(BaseModel):
calendar_id: str
title: str
description: Optional[str] = None
start_at: int
end_at: Optional[int] = None
all_day: bool = False
rrule: Optional[str] = None
color: Optional[str] = None
location: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
attendees: Optional[list[dict]] = None
class CalendarEventUpdateForm(BaseModel):
calendar_id: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
start_at: Optional[int] = None
end_at: Optional[int] = None
all_day: Optional[bool] = None
rrule: Optional[str] = None
color: Optional[str] = None
location: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
is_cancelled: Optional[bool] = None
attendees: Optional[list[dict]] = None
class RSVPForm(BaseModel):
status: str # 'accepted' | 'declined' | 'tentative' | 'pending'
####################
# Response Models
####################
class CalendarEventUserResponse(CalendarEventModel):
user: Optional[UserResponse] = None
class CalendarEventListResponse(BaseModel):
items: list[CalendarEventUserResponse]
total: int
####################
# Table Operations
####################
class CalendarTable:
async def _get_access_grants(self, calendar_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
return await AccessGrants.get_grants_by_resource('calendar', calendar_id, db=db)
async def _to_calendar_model(
self,
cal: Calendar,
access_grants: Optional[list[AccessGrantModel]] = None,
db: Optional[AsyncSession] = None,
) -> CalendarModel:
cal_data = CalendarModel.model_validate(cal).model_dump(exclude={'access_grants'})
cal_data['access_grants'] = (
access_grants if access_grants is not None else await self._get_access_grants(cal_data['id'], db=db)
)
return CalendarModel.model_validate(cal_data)
async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
"""Return user's calendars, creating 'Personal' default if none exist."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(Calendar).filter(Calendar.user_id == user_id).order_by(Calendar.created_at.asc())
)
calendars = result.scalars().all()
if calendars:
return [CalendarModel.model_validate(c) for c in calendars]
now = int(time.time_ns())
cal = Calendar(
id=str(uuid4()),
user_id=user_id,
name='Personal',
color='#3b82f6',
is_default=True,
created_at=now,
updated_at=now,
)
db.add(cal)
await db.commit()
return [CalendarModel.model_validate(cal)]
async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
"""Owned + shared calendars."""
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [g.id for g in user_groups]
stmt = select(Calendar)
stmt = AccessGrants.has_permission_filter(
db=db,
query=stmt,
DocumentModel=Calendar,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='calendar',
permission='read',
)
stmt = stmt.order_by(Calendar.created_at.asc())
result = await db.execute(stmt)
calendars = result.scalars().all()
if not calendars:
return await self.get_or_create_defaults(user_id, db=db)
cal_ids = [c.id for c in calendars]
grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db)
return [await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in calendars]
async def get_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Calendar).filter(Calendar.id == id))
cal = result.scalars().first()
return await self._to_calendar_model(cal, db=db) if cal else None
async def insert_new_calendar(
self, user_id: str, form_data: CalendarForm, db: Optional[AsyncSession] = None
) -> Optional[CalendarModel]:
async with get_async_db_context(db) as db:
now = int(time.time_ns())
cal = Calendar(
id=str(uuid4()),
user_id=user_id,
name=form_data.name,
color=form_data.color,
is_default=False,
data=form_data.data,
meta=form_data.meta,
created_at=now,
updated_at=now,
)
db.add(cal)
await db.commit()
if form_data.access_grants is not None:
await AccessGrants.set_access_grants('calendar', cal.id, form_data.access_grants, db=db)
return await self._to_calendar_model(cal, db=db)
async def update_calendar_by_id(
self, id: str, form_data: CalendarUpdateForm, db: Optional[AsyncSession] = None
) -> Optional[CalendarModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Calendar).filter(Calendar.id == id))
cal = result.scalars().first()
if not cal:
return None
update_data = form_data.model_dump(exclude_unset=True)
if 'name' in update_data:
cal.name = update_data['name']
if 'color' in update_data:
cal.color = update_data['color']
if 'data' in update_data:
cal.data = {**(cal.data or {}), **update_data['data']}
if 'meta' in update_data:
cal.meta = {**(cal.meta or {}), **update_data['meta']}
if 'access_grants' in update_data:
await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db)
cal.updated_at = int(time.time_ns())
await db.commit()
return await self._to_calendar_model(cal, db=db)
async def set_default_calendar(
self, user_id: str, calendar_id: str, db: Optional[AsyncSession] = None
) -> Optional[CalendarModel]:
"""Set a calendar as the user's default, clearing all others."""
async with get_async_db_context(db) as db:
# Clear all defaults for this user
await db.execute(
update(Calendar)
.where(Calendar.user_id == user_id, Calendar.is_default == True)
.values(is_default=False)
)
# Set the new default
result = await db.execute(select(Calendar).filter(Calendar.id == calendar_id, Calendar.user_id == user_id))
cal = result.scalars().first()
if not cal:
return None
cal.is_default = True
cal.updated_at = int(time.time_ns())
await db.commit()
return await self._to_calendar_model(cal, db=db)
async def delete_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete a non-default calendar. Cascades to events, attendees, and grants."""
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Calendar).filter(Calendar.id == id))
cal = result.scalars().first()
if not cal or cal.is_default:
return False
# Delete attendees for all events in this calendar
event_ids_result = await db.execute(select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id))
event_ids = [r[0] for r in event_ids_result.all()]
if event_ids:
await db.execute(
delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
)
# Delete events
await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id))
# Delete access grants
await AccessGrants.revoke_all_access('calendar', id, db=db)
# Delete calendar
await db.execute(delete(Calendar).filter(Calendar.id == id))
await db.commit()
return True
except Exception:
return False
class CalendarEventTable:
async def _get_attendees(
self, event_id: str, db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
rows = result.scalars().all()
return [CalendarEventAttendeeModel.model_validate(r) for r in rows]
async def _to_event_model(
self,
event: CalendarEvent,
attendees: Optional[list[CalendarEventAttendeeModel]] = None,
db: Optional[AsyncSession] = None,
) -> CalendarEventModel:
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
event_data['attendees'] = (
attendees if attendees is not None else await self._get_attendees(event_data['id'], db=db)
)
return CalendarEventModel.model_validate(event_data)
async def insert_new_event(
self, user_id: str, form_data: CalendarEventForm, db: Optional[AsyncSession] = None
) -> Optional[CalendarEventModel]:
async with get_async_db_context(db) as db:
now = int(time.time_ns())
event = CalendarEvent(
id=str(uuid4()),
calendar_id=form_data.calendar_id,
user_id=user_id,
title=form_data.title,
description=form_data.description,
start_at=form_data.start_at,
end_at=form_data.end_at,
all_day=form_data.all_day,
rrule=form_data.rrule,
color=form_data.color,
location=form_data.location,
data=form_data.data,
meta=form_data.meta,
is_cancelled=False,
created_at=now,
updated_at=now,
)
db.add(event)
await db.commit()
# Add attendees
if form_data.attendees:
await CalendarEventAttendees.set_attendees(event.id, form_data.attendees, db=db)
return await self._to_event_model(event, db=db)
async def get_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarEventModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id))
event = result.scalars().first()
return await self._to_event_model(event, db=db) if event else None
async def get_events_by_range(
self,
user_id: str,
start: int,
end: int,
calendar_ids: Optional[list[str]] = None,
db: Optional[AsyncSession] = None,
) -> list[CalendarEventUserResponse]:
"""Fetch events visible to user within a date range.
Visible events = events in owned/shared calendars + events user attends.
Recurring events are fetched if they have any rrule (expansion in Python).
"""
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [g.id for g in user_groups]
# Get calendar IDs accessible to user
cal_stmt = select(Calendar.id)
cal_stmt = AccessGrants.has_permission_filter(
db=db,
query=cal_stmt,
DocumentModel=Calendar,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='calendar',
permission='read',
)
cal_result = await db.execute(cal_stmt)
accessible_cal_ids = [r[0] for r in cal_result.all()]
if calendar_ids:
# Filter to requested calendars only
accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
# Also get event IDs where user is an attendee
attendee_event_ids_result = await db.execute(
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
)
attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
# Build conditions for accessible events
conditions = []
if accessible_cal_ids:
conditions.append(CalendarEvent.calendar_id.in_(accessible_cal_ids))
if attendee_event_ids:
conditions.append(CalendarEvent.id.in_(attendee_event_ids))
if not conditions:
return []
# Build event query
stmt = (
select(CalendarEvent, User)
.outerjoin(User, User.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
or_(*conditions),
or_(
# Non-recurring: overlaps the range
(
CalendarEvent.rrule.is_(None)
& (CalendarEvent.start_at < end)
& or_(
CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start),
CalendarEvent.end_at.isnot(None) & (CalendarEvent.end_at > start),
)
),
# Recurring: fetch all (expansion in Python)
CalendarEvent.rrule.isnot(None),
),
)
.order_by(CalendarEvent.start_at.asc())
)
result = await db.execute(stmt)
items = result.all()
if not items:
return []
# Batch-load attendees for all events in one query (avoid N+1)
event_ids = [event.id for event, _user in items]
att_result = await db.execute(
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
)
att_rows = att_result.scalars().all()
att_map: dict[str, list[CalendarEventAttendeeModel]] = {}
for a in att_rows:
att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a))
events = []
for event, user in items:
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
event_data['attendees'] = att_map.get(event.id, [])
events.append(
CalendarEventUserResponse(
**event_data,
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
return events
async def search_events(
self,
user_id: str,
query: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[AsyncSession] = None,
) -> CalendarEventListResponse:
async with get_async_db_context(db) as db:
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
user_group_ids = [g.id for g in user_groups]
# Get accessible calendar IDs
cal_stmt = select(Calendar.id)
cal_stmt = AccessGrants.has_permission_filter(
db=db,
query=cal_stmt,
DocumentModel=Calendar,
filter={'user_id': user_id, 'group_ids': user_group_ids},
resource_type='calendar',
permission='read',
)
cal_result = await db.execute(cal_stmt)
accessible_cal_ids = [r[0] for r in cal_result.all()]
if not accessible_cal_ids:
return CalendarEventListResponse(items=[], total=0)
stmt = (
select(CalendarEvent, User)
.outerjoin(User, User.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
CalendarEvent.calendar_id.in_(accessible_cal_ids),
)
)
if query:
search = f'%{query}%'
stmt = stmt.filter(
or_(
CalendarEvent.title.ilike(search),
CalendarEvent.description.ilike(search),
CalendarEvent.location.ilike(search),
)
)
stmt = stmt.order_by(CalendarEvent.start_at.desc())
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
items = result.all()
if not items:
return CalendarEventListResponse(items=[], total=total)
# Batch-load attendees
event_ids = [event.id for event, _user in items]
att_result = await db.execute(
select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
)
att_rows = att_result.scalars().all()
att_map: dict[str, list[CalendarEventAttendeeModel]] = {}
for a in att_rows:
att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a))
events = []
for event, user in items:
event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
event_data['attendees'] = att_map.get(event.id, [])
events.append(
CalendarEventUserResponse(
**event_data,
user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
)
)
return CalendarEventListResponse(items=events, total=total)
async def update_event_by_id(
self, id: str, form_data: CalendarEventUpdateForm, db: Optional[AsyncSession] = None
) -> Optional[CalendarEventModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id))
event = result.scalars().first()
if not event:
return None
update_data = form_data.model_dump(exclude_unset=True)
for field in [
'calendar_id',
'title',
'description',
'start_at',
'end_at',
'all_day',
'rrule',
'color',
'location',
'is_cancelled',
]:
if field in update_data:
setattr(event, field, update_data[field])
if 'data' in update_data and update_data['data'] is not None:
event.data = {**(event.data or {}), **update_data['data']}
if 'meta' in update_data and update_data['meta'] is not None:
event.meta = {**(event.meta or {}), **update_data['meta']}
if 'attendees' in update_data and update_data['attendees'] is not None:
await CalendarEventAttendees.set_attendees(id, update_data['attendees'], db=db)
event.updated_at = int(time.time_ns())
await db.commit()
return await self._to_event_model(event, db=db)
async def get_upcoming_events(
self,
now_ns: int,
default_lookahead_ns: int,
db: Optional[AsyncSession] = None,
) -> list[tuple[CalendarEventModel, Optional[str]]]:
"""Events starting between now and now + lookahead, for alert processing.
Per-event lookahead is read from meta.alert_minutes (falls back to
default_lookahead_ns). Returns (event, user_timezone) pairs.
"""
from open_webui.models.users import User as UserRow
# Use the maximum possible lookahead (60 min) to cast a wide net;
# per-event filtering happens in Python after fetching.
max_lookahead_ns = max(default_lookahead_ns, 60 * 60 * 1_000_000_000)
upper = now_ns + max_lookahead_ns
async with get_async_db_context(db) as db:
result = await db.execute(
select(CalendarEvent, UserRow.timezone)
.outerjoin(UserRow, UserRow.id == CalendarEvent.user_id)
.filter(
CalendarEvent.is_cancelled == False,
CalendarEvent.start_at >= now_ns,
CalendarEvent.start_at <= upper,
)
)
rows = result.all()
events = []
for event, tz in rows:
model = CalendarEventModel.model_validate(event)
# Determine per-event alert window
alert_minutes = None
if model.meta and 'alert_minutes' in model.meta:
alert_minutes = model.meta['alert_minutes']
if alert_minutes is not None:
if alert_minutes < 0:
# alert_minutes < 0 means "no alert"
continue
event_lookahead_ns = alert_minutes * 60 * 1_000_000_000
else:
event_lookahead_ns = default_lookahead_ns
if model.start_at <= now_ns + event_lookahead_ns:
events.append((model, tz))
return events
async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
try:
async with get_async_db_context(db) as db:
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id))
await db.execute(delete(CalendarEvent).filter(CalendarEvent.id == id))
await db.commit()
return True
except Exception:
return False
class CalendarEventAttendeeTable:
async def set_attendees(
self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
"""Replace all attendees for an event.
Each dict in attendees: {user_id: str, status?: str, meta?: dict}
"""
async with get_async_db_context(db) as db:
# Remove existing
await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
now = int(time.time_ns())
models = []
for att in attendees:
row = CalendarEventAttendee(
id=str(uuid4()),
event_id=event_id,
user_id=att['user_id'],
status=att.get('status', 'pending'),
meta=att.get('meta'),
created_at=now,
updated_at=now,
)
db.add(row)
models.append(CalendarEventAttendeeModel.model_validate(row))
await db.commit()
return models
async def update_rsvp(
self, event_id: str, user_id: str, status: str, db: Optional[AsyncSession] = None
) -> Optional[CalendarEventAttendeeModel]:
async with get_async_db_context(db) as db:
result = await db.execute(
select(CalendarEventAttendee).filter(
CalendarEventAttendee.event_id == event_id,
CalendarEventAttendee.user_id == user_id,
)
)
att = result.scalars().first()
if not att:
return None
att.status = status
att.updated_at = int(time.time_ns())
await db.commit()
return CalendarEventAttendeeModel.model_validate(att)
async def get_attendees_by_event(
self, event_id: str, db: Optional[AsyncSession] = None
) -> list[CalendarEventAttendeeModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
return [CalendarEventAttendeeModel.model_validate(r) for r in result.scalars().all()]
async def get_events_by_attendee(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
"""Return event IDs where user is an attendee."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
)
return [r[0] for r in result.all()]
Calendars = CalendarTable()
CalendarEvents = CalendarEventTable()
CalendarEventAttendees = CalendarEventAttendeeTable()

View file

@ -313,7 +313,6 @@ class ChatMessageTable:
stmt = select(ChatMessage.model_id, func.count(ChatMessage.id).label('count')).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like('shared-%'),
)
if start_date:
@ -368,7 +367,6 @@ class ChatMessageTable:
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
ChatMessage.usage.isnot(None),
~ChatMessage.user_id.like('shared-%'),
)
if start_date:
@ -430,7 +428,6 @@ class ChatMessageTable:
ChatMessage.role == 'assistant',
ChatMessage.user_id.isnot(None),
ChatMessage.usage.isnot(None),
~ChatMessage.user_id.like('shared-%'),
)
if start_date:
@ -465,7 +462,7 @@ class ChatMessageTable:
from open_webui.models.groups import GroupMember
stmt = select(ChatMessage.user_id, func.count(ChatMessage.id).label('count')).filter(
~ChatMessage.user_id.like('shared-%')
ChatMessage.role == 'assistant',
)
if start_date:
@ -491,7 +488,7 @@ class ChatMessageTable:
from open_webui.models.groups import GroupMember
stmt = select(ChatMessage.chat_id, func.count(ChatMessage.id).label('count')).filter(
~ChatMessage.user_id.like('shared-%')
ChatMessage.role == 'assistant',
)
if start_date:
@ -521,7 +518,6 @@ class ChatMessageTable:
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like('shared-%'),
)
if start_date:
@ -568,7 +564,6 @@ class ChatMessageTable:
stmt = select(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == 'assistant',
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like('shared-%'),
)
if start_date:

View file

@ -555,77 +555,51 @@ class ChatTable:
async def insert_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[AsyncSession] = None
) -> Optional[ChatModel]:
"""Create a shared snapshot for a chat. Returns the original chat with share_id set."""
from open_webui.models.shared_chats import SharedChats
async with get_async_db_context(db) as db:
# Get the existing chat to share
chat = await db.get(Chat, chat_id)
# Check if chat exists
if not chat:
return None
# Check if the chat is already shared
if chat.share_id:
return await 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()),
}
)
shared_result = Chat(**shared_chat.model_dump())
db.add(shared_result)
await db.commit()
await db.refresh(shared_result)
# Update the original chat with the share_id
await db.execute(update(Chat).filter_by(id=chat_id).values(share_id=shared_chat.id))
# If already shared, just update the existing snapshot
if chat.share_id:
return await self.update_shared_chat_by_chat_id(chat_id, db=db)
shared = await SharedChats.create(chat_id, chat.user_id, db=db)
if not shared:
return None
# Set share_id on the original chat
chat.share_id = shared.id
await db.commit()
return shared_chat if shared_result else None
await db.refresh(chat)
return ChatModel.model_validate(chat)
async def update_shared_chat_by_chat_id(
self, chat_id: str, db: Optional[AsyncSession] = None
) -> Optional[ChatModel]:
"""Re-snapshot the shared chat with current chat data."""
from open_webui.models.shared_chats import SharedChats
try:
async with get_async_db_context(db) as db:
chat = await db.get(Chat, chat_id)
result = await db.execute(select(Chat).filter_by(user_id=f'shared-{chat_id}'))
shared_chat = result.scalars().first()
if shared_chat is None:
if not chat or not chat.share_id:
return await self.insert_shared_chat_by_chat_id(chat_id, db=db)
shared_chat.title = chat.title
shared_chat.chat = chat.chat
shared_chat.meta = chat.meta
shared_chat.pinned = chat.pinned
shared_chat.folder_id = chat.folder_id
shared_chat.updated_at = int(time.time())
await db.commit()
await db.refresh(shared_chat)
return ChatModel.model_validate(shared_chat)
await SharedChats.update(chat.share_id, db=db)
return ChatModel.model_validate(chat)
except Exception:
return None
async def delete_shared_chat_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete shared snapshot for a chat."""
from open_webui.models.shared_chats import SharedChats
try:
async with get_async_db_context(db) as db:
# Get shared chat IDs
result = await db.execute(select(Chat.id).filter_by(user_id=f'shared-{chat_id}'))
shared_ids = [row[0] for row in result.all()]
if shared_ids:
await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids)))
await db.execute(delete(Chat).filter_by(user_id=f'shared-{chat_id}'))
await db.commit()
return True
return await SharedChats.delete_by_chat_id(chat_id, db=db)
except Exception:
return False
@ -746,53 +720,10 @@ class ChatTable:
limit: int = 50,
db: Optional[AsyncSession] = None,
) -> list[SharedChatResponse]:
async with get_async_db_context(db) as db:
stmt = (
select(Chat.id, Chat.title, Chat.share_id, Chat.updated_at, Chat.created_at)
.filter_by(user_id=user_id)
.filter(Chat.share_id.isnot(None))
)
"""Delegate to SharedChats for listing shared chats by user."""
from open_webui.models.shared_chats import SharedChats
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(Chat.title.ilike(f'%{query_key}%'))
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')
if direction.lower() == 'asc':
stmt = stmt.order_by(getattr(Chat, order_by).asc(), Chat.id)
elif direction.lower() == 'desc':
stmt = stmt.order_by(getattr(Chat, order_by).desc(), Chat.id)
else:
raise ValueError('Invalid direction for ordering')
else:
stmt = stmt.order_by(Chat.updated_at.desc(), Chat.id)
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
all_chats = result.all()
return [
SharedChatResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'share_id': chat[2],
'updated_at': chat[3],
'created_at': chat[4],
}
)
for chat in all_chats
]
return await SharedChats.get_by_user_id(user_id, filter=filter, skip=skip, limit=limit, db=db)
async def get_chat_list_by_user_id(
self,
@ -925,15 +856,23 @@ class ChatTable:
return None
async def get_chat_by_share_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[ChatModel]:
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Chat).filter_by(share_id=id))
chat = result.scalars().first()
"""Look up a shared chat snapshot by its share token."""
from open_webui.models.shared_chats import SharedChats
if chat:
return await self.get_chat_by_id(id, db=db)
else:
return None
try:
shared = await SharedChats.get_by_id(id, db=db)
if shared:
# Return a ChatModel-compatible view of the snapshot
return ChatModel(
id=shared.id,
user_id=shared.user_id,
title=shared.title,
chat=shared.chat,
created_at=shared.created_at,
updated_at=shared.updated_at,
share_id=shared.id,
)
return None
except Exception:
return None
@ -1323,8 +1262,10 @@ class ChatTable:
self, id: str, user_id: str, db: Optional[AsyncSession] = None
) -> list[TagModel]:
async with get_async_db_context(db) as db:
chat = await db.get(Chat, id)
tag_ids = chat.meta.get('tags', [])
stmt = select(Chat.meta).where(Chat.id == id)
result = await db.execute(stmt)
meta = result.scalar_one_or_none()
tag_ids = (meta or {}).get('tags', [])
return await Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db)
async def get_chat_list_by_user_id_and_tag_name(
@ -1568,20 +1509,17 @@ class ChatTable:
return False
async def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all shared chat snapshots created by a user."""
from open_webui.models.shared_chats import SharedChats, SharedChat as SharedChatTable
try:
async with get_async_db_context(db) as db:
result = await db.execute(select(Chat.id).filter_by(user_id=user_id))
id_rows = result.all()
shared_chat_ids = [f'shared-{row[0]}' for row in id_rows]
# Delete shared_chat rows for this user's chats
await db.execute(delete(SharedChatTable).filter_by(user_id=user_id))
if shared_chat_ids:
# Get shared chat IDs to delete associated messages
shared_result = await db.execute(select(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)))
shared_ids = [row[0] for row in shared_result.all()]
if shared_ids:
await db.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(shared_ids)))
await db.execute(delete(Chat).filter(Chat.user_id.in_(shared_chat_ids)))
await db.commit()
# Clear share_id on all of this user's chats
await db.execute(update(Chat).filter_by(user_id=user_id).values(share_id=None))
await db.commit()
return True
except Exception:
@ -1598,11 +1536,11 @@ class ChatTable:
if not file_ids:
return None
chat_message_file_ids = [
chat_message_file_ids = {
item.id for item in await 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({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
@ -1651,16 +1589,15 @@ class ChatTable:
except Exception:
return False
async def get_shared_chats_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[ChatModel]:
async def get_shared_chat_ids_by_file_id(self, file_id: str, db: Optional[AsyncSession] = None) -> list[str]:
"""Return IDs of chats that contain this file and have an active share link."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(Chat)
select(Chat.id)
.join(ChatFile, Chat.id == ChatFile.chat_id)
.filter(ChatFile.file_id == file_id, Chat.share_id.isnot(None))
)
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
return [row[0] for row in result.all()]
async def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]:
"""Update the tasks list on a chat."""

View file

@ -3,7 +3,7 @@ import time
import uuid
from typing import Optional
from sqlalchemy import select, delete, update, or_, func, cast, String
from sqlalchemy import select, delete, update, or_, func, text, cast, String
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.groups import Groups
@ -260,12 +260,12 @@ class PromptsTable:
) -> PromptListResponse:
async with get_async_db_context(db) as db:
# Join with User table for user filtering and sorting
stmt = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
query = select(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(
query = query.filter(
or_(
Prompt.name.ilike(f'%{query_key}%'),
Prompt.command.ilike(f'%{query_key}%'),
@ -277,14 +277,14 @@ class PromptsTable:
view_option = filter.get('view_option')
if view_option == 'created':
stmt = stmt.filter(Prompt.user_id == user_id)
query = query.filter(Prompt.user_id == user_id)
elif view_option == 'shared':
stmt = stmt.filter(Prompt.user_id != user_id)
query = query.filter(Prompt.user_id != user_id)
# Apply access grant filtering
stmt = AccessGrants.has_permission_filter(
query = AccessGrants.has_permission_filter(
db=db,
query=stmt,
query=query,
DocumentModel=Prompt,
filter=filter,
resource_type='prompt',
@ -293,56 +293,63 @@ class PromptsTable:
tag = filter.get('tag')
if tag:
# SQLite stores JSON text via json.dumps(ensure_ascii=True),
# so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB
# stores literal Unicode. Use the right pattern for each.
if db.bind.dialect.name == 'sqlite':
if tag.isascii():
tags_text = func.lower(cast(Prompt.tags, String))
pattern = f'%{json.dumps(tag.lower())}%'
else:
# LOWER() is ASCII-only; non-ASCII codepoints would
# produce different \uXXXX escapes when lowered.
tags_text = cast(Prompt.tags, String)
pattern = f'%{json.dumps(tag)}%'
bind = await db.connection()
dialect_name = bind.dialect.name
tag_lower = tag.lower()
if dialect_name == 'sqlite':
tag_clause = text(
'EXISTS (SELECT 1 FROM json_each(prompt.tags) t WHERE LOWER(t.value) = :tag_val)'
)
elif dialect_name == 'postgresql':
tag_clause = text(
'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)'
)
else:
tags_text = func.lower(cast(Prompt.tags, String))
pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%'
stmt = stmt.filter(tags_text.like(pattern))
# Fallback: LIKE on serialised JSON text (ASCII-safe only)
tag_clause = func.lower(cast(Prompt.tags, String)).like(
f'%{json.dumps(tag_lower, ensure_ascii=False)}%'
)
tag_lower = None
if tag_lower is not None:
query = query.filter(tag_clause.params(tag_val=tag_lower))
else:
query = query.filter(tag_clause)
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by == 'name':
if direction == 'asc':
stmt = stmt.order_by(Prompt.name.asc())
query = query.order_by(Prompt.name.asc())
else:
stmt = stmt.order_by(Prompt.name.desc())
query = query.order_by(Prompt.name.desc())
elif order_by == 'created_at':
if direction == 'asc':
stmt = stmt.order_by(Prompt.created_at.asc())
query = query.order_by(Prompt.created_at.asc())
else:
stmt = stmt.order_by(Prompt.created_at.desc())
query = query.order_by(Prompt.created_at.desc())
elif order_by == 'updated_at':
if direction == 'asc':
stmt = stmt.order_by(Prompt.updated_at.asc())
query = query.order_by(Prompt.updated_at.asc())
else:
stmt = stmt.order_by(Prompt.updated_at.desc())
query = query.order_by(Prompt.updated_at.desc())
else:
stmt = stmt.order_by(Prompt.updated_at.desc())
query = query.order_by(Prompt.updated_at.desc())
else:
stmt = stmt.order_by(Prompt.updated_at.desc())
query = query.order_by(Prompt.updated_at.desc())
# Count BEFORE pagination
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
count_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = count_result.scalar()
if skip:
stmt = stmt.offset(skip)
query = query.offset(skip)
if limit:
stmt = stmt.limit(limit)
query = query.limit(limit)
result = await db.execute(stmt)
result = await db.execute(query)
items = result.all()
prompt_ids = [prompt.id for prompt, _ in items]

View file

@ -0,0 +1,207 @@
import logging
import time
import uuid
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, JSONField, get_async_db_context
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Column, ForeignKey, Text, JSON
log = logging.getLogger(__name__)
####################
# SharedChat DB Schema
####################
class SharedChat(Base):
__tablename__ = 'shared_chat'
id = Column(Text, primary_key=True) # The share token (UUID) — used in /s/{id} URL
chat_id = Column(Text, ForeignKey('chat.id', ondelete='CASCADE'), nullable=False)
user_id = Column(Text, nullable=False) # Who created this share
title = Column(Text)
chat = Column(JSON) # Snapshot of chat JSON at share time
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class SharedChatModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
chat_id: str
user_id: str
title: str
chat: dict
created_at: int
updated_at: int
class SharedChatResponse(BaseModel):
id: str
chat_id: str
title: str
share_id: Optional[str] = None # Alias for id, for backward compat
updated_at: int
created_at: int
####################
# Table Operations
####################
class SharedChatsTable:
async def create(self, chat_id: str, user_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""
Create a snapshot of the chat for link sharing.
Returns the SharedChatModel with the share token as its id.
"""
async with get_async_db_context(db) as db:
from open_webui.models.chats import Chat
chat = await db.get(Chat, chat_id)
if not chat:
return None
share_id = str(uuid.uuid4())
now = int(time.time())
shared_chat = SharedChat(
id=share_id,
chat_id=chat_id,
user_id=user_id,
title=chat.title,
chat=chat.chat,
created_at=now,
updated_at=now,
)
db.add(shared_chat)
await db.commit()
await db.refresh(shared_chat)
return SharedChatModel.model_validate(shared_chat)
async def update(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""
Re-snapshot: update the shared chat with the current state of the original chat.
"""
async with get_async_db_context(db) as db:
from open_webui.models.chats import Chat
shared_chat = await db.get(SharedChat, share_id)
if not shared_chat:
return None
chat = await db.get(Chat, shared_chat.chat_id)
if not chat:
return None
shared_chat.title = chat.title
shared_chat.chat = chat.chat
shared_chat.updated_at = int(time.time())
await db.commit()
await db.refresh(shared_chat)
return SharedChatModel.model_validate(shared_chat)
async def get_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""Get a shared chat by its share token."""
async with get_async_db_context(db) as db:
shared_chat = await db.get(SharedChat, share_id)
if shared_chat:
return SharedChatModel.model_validate(shared_chat)
return None
async def get_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[SharedChatModel]:
"""Get the shared chat for a given original chat. Returns the most recent one."""
async with get_async_db_context(db) as db:
result = await db.execute(
select(SharedChat).filter_by(chat_id=chat_id).order_by(SharedChat.updated_at.desc()).limit(1)
)
shared_chat = result.scalars().first()
if shared_chat:
return SharedChatModel.model_validate(shared_chat)
return None
async def get_by_user_id(
self,
user_id: str,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
db: Optional[AsyncSession] = None,
) -> list[SharedChatResponse]:
"""List all shared chats created by a user."""
async with get_async_db_context(db) as db:
stmt = select(SharedChat).filter_by(user_id=user_id)
if filter:
query_key = filter.get('query')
if query_key:
stmt = stmt.filter(SharedChat.title.ilike(f'%{query_key}%'))
order_by = filter.get('order_by')
direction = filter.get('direction')
if order_by and direction:
col = getattr(SharedChat, order_by, None)
if not col:
raise ValueError('Invalid order_by field')
if direction.lower() == 'asc':
stmt = stmt.order_by(col.asc())
elif direction.lower() == 'desc':
stmt = stmt.order_by(col.desc())
else:
raise ValueError('Invalid direction for ordering')
else:
stmt = stmt.order_by(SharedChat.updated_at.desc())
if skip:
stmt = stmt.offset(skip)
if limit:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
return [
SharedChatResponse(
id=sc.chat_id,
chat_id=sc.chat_id,
title=sc.title,
share_id=sc.id,
updated_at=sc.updated_at,
created_at=sc.created_at,
)
for sc in result.scalars().all()
]
async def delete_by_id(self, share_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete a shared chat by its share token."""
try:
async with get_async_db_context(db) as db:
await db.execute(delete(SharedChat).filter_by(id=share_id))
await db.commit()
return True
except Exception:
return False
async def delete_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> bool:
"""Delete all shared chats for a given original chat."""
try:
async with get_async_db_context(db) as db:
await db.execute(delete(SharedChat).filter_by(chat_id=chat_id))
await db.commit()
return True
except Exception:
return False
SharedChats = SharedChatsTable()

View file

@ -10,7 +10,7 @@ from typing import List, Dict, Any
from contextlib import asynccontextmanager
from langchain_core.documents import Document
from open_webui.env import GLOBAL_LOG_LEVEL
from open_webui.env import GLOBAL_LOG_LEVEL, AIOHTTP_CLIENT_SESSION_SSL
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
@ -292,6 +292,7 @@ class MistralLoader:
data=writer,
headers=self.headers,
timeout=aiohttp.ClientTimeout(total=self.upload_timeout),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await self._handle_response_async(response)
@ -340,6 +341,7 @@ class MistralLoader:
headers=headers,
params=params,
timeout=aiohttp.ClientTimeout(total=self.url_timeout),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await self._handle_response_async(response)
@ -411,6 +413,7 @@ class MistralLoader:
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=self.ocr_timeout),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
ocr_response = await self._handle_response_async(response)
@ -533,7 +536,8 @@ class MistralLoader:
async with session.delete(
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),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
return await self._handle_response_async(response)

View file

@ -59,14 +59,14 @@ class ColBERT(BaseReranker):
return normalized_scores.detach().cpu().numpy().astype(np.float32)
def predict(self, sentences):
def predict(self, sentences, batch_size=32):
query = sentences[0][0]
docs = [i[1] for i in sentences]
# Embedding the documents
embedded_docs = self.ckpt.docFromText(docs, bsize=32)[0]
embedded_docs = self.ckpt.docFromText(docs, bsize=batch_size)[0]
# Embedding the queries
embedded_queries = self.ckpt.queryFromText([query], bsize=32)
embedded_queries = self.ckpt.queryFromText([query], bsize=batch_size)
embedded_query = embedded_queries[0]
# Calculate retrieval scores for the query against all documents

View file

@ -83,11 +83,123 @@ def get_loader(request, url: str):
)
def build_loader_from_config(request):
"""Build a Loader instance with the admin's configured extraction engine settings."""
from open_webui.retrieval.loaders.main import Loader
config = request.app.state.config
return Loader(
engine=config.CONTENT_EXTRACTION_ENGINE,
DATALAB_MARKER_API_KEY=config.DATALAB_MARKER_API_KEY,
DATALAB_MARKER_API_BASE_URL=config.DATALAB_MARKER_API_BASE_URL,
DATALAB_MARKER_ADDITIONAL_CONFIG=config.DATALAB_MARKER_ADDITIONAL_CONFIG,
DATALAB_MARKER_SKIP_CACHE=config.DATALAB_MARKER_SKIP_CACHE,
DATALAB_MARKER_FORCE_OCR=config.DATALAB_MARKER_FORCE_OCR,
DATALAB_MARKER_PAGINATE=config.DATALAB_MARKER_PAGINATE,
DATALAB_MARKER_STRIP_EXISTING_OCR=config.DATALAB_MARKER_STRIP_EXISTING_OCR,
DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION=config.DATALAB_MARKER_DISABLE_IMAGE_EXTRACTION,
DATALAB_MARKER_FORMAT_LINES=config.DATALAB_MARKER_FORMAT_LINES,
DATALAB_MARKER_USE_LLM=config.DATALAB_MARKER_USE_LLM,
DATALAB_MARKER_OUTPUT_FORMAT=config.DATALAB_MARKER_OUTPUT_FORMAT,
EXTERNAL_DOCUMENT_LOADER_URL=config.EXTERNAL_DOCUMENT_LOADER_URL,
EXTERNAL_DOCUMENT_LOADER_API_KEY=config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
TIKA_SERVER_URL=config.TIKA_SERVER_URL,
DOCLING_SERVER_URL=config.DOCLING_SERVER_URL,
DOCLING_API_KEY=config.DOCLING_API_KEY,
DOCLING_PARAMS=config.DOCLING_PARAMS,
PDF_EXTRACT_IMAGES=config.PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE=config.PDF_LOADER_MODE,
DOCUMENT_INTELLIGENCE_ENDPOINT=config.DOCUMENT_INTELLIGENCE_ENDPOINT,
DOCUMENT_INTELLIGENCE_KEY=config.DOCUMENT_INTELLIGENCE_KEY,
DOCUMENT_INTELLIGENCE_MODEL=config.DOCUMENT_INTELLIGENCE_MODEL,
MISTRAL_OCR_API_BASE_URL=config.MISTRAL_OCR_API_BASE_URL,
MISTRAL_OCR_API_KEY=config.MISTRAL_OCR_API_KEY,
MINERU_API_MODE=config.MINERU_API_MODE,
MINERU_API_URL=config.MINERU_API_URL,
MINERU_API_KEY=config.MINERU_API_KEY,
MINERU_API_TIMEOUT=config.MINERU_API_TIMEOUT,
MINERU_PARAMS=config.MINERU_PARAMS,
)
def _extract_text_from_binary_response(request, response: requests.Response, url: str) -> tuple[str, list]:
"""Download response body to a temp file and extract text using the Loader pipeline."""
import mimetypes
import tempfile
import urllib.parse
content_type = response.headers.get('Content-Type', '').split(';')[0].strip()
# Derive filename from URL path, falling back to Content-Disposition or mime guess
url_path = urllib.parse.urlparse(url).path
filename = os.path.basename(url_path) if url_path else ''
if not filename or '.' not in filename:
# Try Content-Disposition header
cd = response.headers.get('Content-Disposition', '')
if 'filename=' in cd:
filename = cd.split('filename=')[-1].strip('"\'')
if not filename or '.' not in filename:
ext = mimetypes.guess_extension(content_type) or ''
filename = f'download{ext}'
suffix = '.' + filename.split('.')[-1].lower() if '.' in filename else ''
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(response.content)
tmp_path = tmp.name
try:
loader = build_loader_from_config(request)
docs = loader.load(filename, content_type, tmp_path)
for doc in docs:
doc.metadata['source'] = url
content = ' '.join([doc.page_content for doc in docs])
return content, docs
finally:
os.remove(tmp_path)
def _is_text_content_type(content_type: str) -> bool:
"""Return True if the content type should be handled by the web loader."""
ct = content_type.split(';')[0].strip().lower()
if ct.startswith('text/'):
return True
if any(t in ct for t in ['xml', 'json', 'javascript']):
return True
return not ct # empty / missing → assume HTML
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])
return content, docs
from open_webui.retrieval.web.utils import validate_url
# Validate URL before making any request (blocks private IPs, non-HTTP, filter list)
validate_url(url)
# Streamed GET to check Content-Type without downloading the body.
try:
response = requests.get(url, stream=True, timeout=30)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
except Exception:
content_type = ''
response = None
# Text / HTML / unknown — use the configured web loader
if response is None or _is_text_content_type(content_type):
if response is not None:
response.close()
loader = get_loader(request, url)
docs = loader.load()
content = ' '.join([doc.page_content for doc in docs])
return content, docs
# Binary content (PDF, DOCX, XLSX, PPTX, etc.) — download and extract
try:
return _extract_text_from_binary_response(request, response, url)
finally:
response.close()
CHUNK_HASH_KEY = '_chunk_hash'
@ -919,7 +1031,7 @@ async def generate_embeddings(
return embeddings[0] if isinstance(text, str) else embeddings
def get_reranking_function(reranking_engine, reranking_model, reranking_function):
def get_reranking_function(reranking_engine, reranking_model, reranking_function, reranking_batch_size=32):
if reranking_function is None:
return None
if reranking_engine == 'external':
@ -928,10 +1040,61 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function
)
else:
return lambda query, documents, user=None: reranking_function.predict(
[(query, doc.page_content) for doc in documents]
[(query, doc.page_content) for doc in documents], batch_size=int(reranking_batch_size)
)
async def filter_accessible_collections(
collection_names: set[str],
user: UserModel,
access_type: str = 'read',
) -> set[str]:
"""
Return only the collection names the user is allowed to access.
Admins bypass all checks. For non-admins the policy is:
- file-* validated via has_access_to_file
- user-memory-* must match user's own memory collection
- web-search-* ephemeral per-query collections, always allowed
- knowledge-bases always denied (system meta-collection)
- everything else if the name matches a knowledge base, validated
via Knowledges.check_access_by_user_id; if no
such KB exists, the name is treated as an
ephemeral/legacy collection and allowed
"""
if user.role == 'admin':
return collection_names
validated = set()
for name in collection_names:
if name == 'knowledge-bases':
# System meta-collection — never exposed to non-admins.
continue
elif name.startswith('file-'):
file_id = name[len('file-') :]
if await has_access_to_file(file_id=file_id, access_type=access_type, user=user):
validated.add(name)
elif name.startswith('user-memory-'):
if name == f'user-memory-{user.id}':
validated.add(name)
elif name.startswith('web-search-'):
# Ephemeral collections created by process_web_search — safe
# to allow because they contain only transient web-search
# results scoped to the requesting user's session.
validated.add(name)
else:
# May be a knowledge-base ID or a legacy/ephemeral collection.
# If it IS a KB, enforce access control. If no such KB
# exists, treat it as a non-sensitive collection (e.g. legacy
# model knowledge, process_text SHA256 collections) and allow.
if await Knowledges.check_access_by_user_id(name, user.id, permission=access_type):
validated.add(name)
elif not await Knowledges.get_knowledge_by_id(name):
# Not a KB at all — legacy/ephemeral collection, allow
validated.add(name)
return validated
async def get_sources_from_items(
request,
items,
@ -1147,6 +1310,13 @@ async def get_sources_from_items(
log.debug(f'skipping {item} as it has already been extracted')
continue
# Filter out collections the user cannot read
if user:
collection_names = await filter_accessible_collections(collection_names, user)
if not collection_names:
log.debug(f'access denied for all collections in item {item}')
continue
try:
if full_context:
# Sync helper makes blocking VECTOR_DB_CLIENT calls;

View file

@ -45,6 +45,7 @@ from open_webui.config import (
WEB_FETCH_FILTER_LIST,
)
from open_webui.utils.misc import is_string_allowed
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
log = logging.getLogger(__name__)
@ -511,6 +512,8 @@ class SafeWebBaseLoader(WebBaseLoader):
)
if not self.session.verify:
kwargs['ssl'] = False
else:
kwargs['ssl'] = AIOHTTP_CLIENT_SESSION_SSL
async with session.get(
url,

View file

@ -5,7 +5,6 @@ import os
import uuid
import html
import base64
from functools import lru_cache
from pydub import AudioSegment
from pydub.silence import split_on_silence
from concurrent.futures import ThreadPoolExecutor
@ -421,7 +420,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
elif request.app.state.config.TTS_ENGINE == 'elevenlabs':
voice_id = payload.get('voice', '')
if voice_id not in get_available_voices(request):
if voice_id not in await get_available_voices(request):
raise HTTPException(
status_code=400,
detail='Invalid voice id',
@ -975,7 +974,10 @@ def transcription_handler(request, file_path, metadata, user=None):
# 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')
audio_base64 = {
'data': base64.b64encode(audio_file.read()).decode('utf-8'),
'format': mimetypes.guess_extension(mimetypes.guess_type(audio_file_to_use)[0]).lstrip('.'),
}
# Prepare chat completions request
url = f'{api_base_url}/chat/completions'
@ -1292,40 +1294,56 @@ async def transcription(
)
def get_available_models(request: Request) -> list[dict]:
async def get_available_models(request: Request) -> list[dict]:
available_models = []
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'):
try:
response = requests.get(
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', [])
except Exception as e:
log.error(f'Error fetching models from custom endpoint: {str(e)}')
available_models = [{'id': 'tts-1'}, {'id': 'tts-1-hd'}]
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
try:
async with session.get(
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/models',
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
available_models = data.get('models', [])
except Exception as e:
log.debug(f'/audio/models not available, trying /models fallback: {str(e)}')
# Fallback to standard OpenAI-compatible /models endpoint
# (used by KokoroTTS and similar custom TTS servers)
try:
async with session.get(
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/models',
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
# OpenAI /models returns {"data": [...]}, /audio/models returns {"models": [...]}
available_models = data.get('data', data.get('models', []))
except Exception as e2:
log.error(f'Error fetching models from custom endpoint: {str(e2)}')
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':
try:
response = requests.get(
f'{ELEVENLABS_API_BASE_URL}/v1/models',
headers={
'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]
except requests.RequestException as e:
log.error(f'Error fetching voices: {str(e)}')
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{ELEVENLABS_API_BASE_URL}/v1/models',
headers={
'xi-api-key': request.app.state.config.TTS_API_KEY,
'Content-Type': 'application/json',
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
models = await response.json()
available_models = [{'name': model['name'], 'id': model['model_id']} for model in models]
except Exception as e:
log.error(f'Error fetching models: {str(e)}')
elif request.app.state.config.TTS_ENGINE == 'mistral':
available_models = [{'id': 'mistral-tts-latest'}]
return available_models
@ -1333,24 +1351,26 @@ def get_available_models(request: Request) -> list[dict]:
@router.get('/models')
async def get_models(request: Request, user=Depends(get_verified_user)):
return {'models': get_available_models(request)}
return {'models': await get_available_models(request)}
def get_available_voices(request) -> dict:
async def get_available_voices(request) -> dict:
"""Returns {voice_id: voice_name} dict"""
available_voices = {}
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'):
try:
response = requests.get(
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}
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/voices',
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
data = await response.json()
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)}')
available_voices = {
@ -1372,7 +1392,7 @@ def get_available_voices(request) -> dict:
}
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 = await get_elevenlabs_voices(api_key=request.app.state.config.TTS_API_KEY)
except Exception:
# Avoided @lru_cache with exception
pass
@ -1383,13 +1403,15 @@ def get_available_voices(request) -> dict:
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.raise_for_status()
voices = response.json()
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response:
response.raise_for_status()
voices = await response.json()
for voice in voices:
available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})'
except requests.RequestException as e:
for voice in voices:
available_voices[voice['ShortName']] = f'{voice["DisplayName"]} ({voice["ShortName"]})'
except Exception as e:
log.error(f'Error fetching voices: {str(e)}')
elif request.app.state.config.TTS_ENGINE == 'mistral':
api_key = request.app.state.config.TTS_MISTRAL_API_KEY
@ -1397,29 +1419,30 @@ def get_available_voices(request) -> dict:
if api_key:
try:
response = requests.get(
f'{api_base_url}/audio/voices',
headers={
'Authorization': f'Bearer {api_key}',
},
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
voices_data = response.json()
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{api_base_url}/audio/voices',
headers={
'Authorization': f'Bearer {api_key}',
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
voices_data = await response.json()
for voice in voices_data:
voice_id = voice.get('voice_id', voice.get('id', ''))
voice_name = voice.get('name', voice_id)
if voice_id:
available_voices[voice_id] = voice_name
except requests.RequestException as e:
for voice in voices_data:
voice_id = voice.get('voice_id', voice.get('id', ''))
voice_name = voice.get('name', voice_id)
if voice_id:
available_voices[voice_id] = voice_name
except Exception as e:
log.error(f'Error fetching Mistral voices: {str(e)}')
return available_voices
@lru_cache
def get_elevenlabs_voices(api_key: str) -> dict:
async def get_elevenlabs_voices(api_key: str) -> dict:
"""
Note, set the following in your .env file to use Elevenlabs:
AUDIO_TTS_ENGINE=elevenlabs
@ -1430,22 +1453,23 @@ def get_elevenlabs_voices(api_key: str) -> dict:
try:
# TODO: Add retries
response = requests.get(
f'{ELEVENLABS_API_BASE_URL}/v1/voices',
headers={
'xi-api-key': api_key,
'Content-Type': 'application/json',
},
timeout=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
)
response.raise_for_status()
voices_data = response.json()
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{ELEVENLABS_API_BASE_URL}/v1/voices',
headers={
'xi-api-key': api_key,
'Content-Type': 'application/json',
},
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as response:
response.raise_for_status()
voices_data = await response.json()
voices = {}
for voice in voices_data.get('voices', []):
voices[voice['voice_id']] = voice['name']
except requests.RequestException as e:
# Avoid @lru_cache with exception
voices = {}
for voice in voices_data.get('voices', []):
voices[voice['voice_id']] = voice['name']
except Exception as e:
log.error(f'Error fetching voices: {str(e)}')
raise RuntimeError(f'Error fetching voices: {str(e)}')
@ -1454,4 +1478,4 @@ def get_elevenlabs_voices(api_key: str) -> dict:
@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 (await get_available_voices(request)).items()]}

View file

@ -810,7 +810,7 @@ async def signout(request: Request, response: Response, db: AsyncSession = Depen
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:
async with session.get(oauth_server_metadata_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
if r.status == 200:
openid_data = await r.json()
logout_url = openid_data.get('end_session_endpoint')
@ -971,7 +971,9 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT,
'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT,
'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL,
'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS,
'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS,
'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR,
'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,
@ -999,7 +1001,9 @@ class AdminConfig(BaseModel):
FOLDER_MAX_FILE_COUNT: Optional[int | str] = None
AUTOMATION_MAX_COUNT: Optional[int | str] = None
AUTOMATION_MIN_INTERVAL: Optional[int | str] = None
ENABLE_AUTOMATIONS: bool
ENABLE_CHANNELS: bool
ENABLE_CALENDAR: bool
ENABLE_MEMORIES: bool
ENABLE_NOTES: bool
ENABLE_USER_WEBHOOKS: bool
@ -1030,7 +1034,9 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep
request.app.state.config.AUTOMATION_MIN_INTERVAL = (
int(form_data.AUTOMATION_MIN_INTERVAL) if form_data.AUTOMATION_MIN_INTERVAL else ''
)
request.app.state.config.ENABLE_AUTOMATIONS = form_data.ENABLE_AUTOMATIONS
request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
request.app.state.config.ENABLE_CALENDAR = form_data.ENABLE_CALENDAR
request.app.state.config.ENABLE_MEMORIES = form_data.ENABLE_MEMORIES
request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
@ -1073,7 +1079,9 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep
'FOLDER_MAX_FILE_COUNT': request.app.state.config.FOLDER_MAX_FILE_COUNT,
'AUTOMATION_MAX_COUNT': request.app.state.config.AUTOMATION_MAX_COUNT,
'AUTOMATION_MIN_INTERVAL': request.app.state.config.AUTOMATION_MIN_INTERVAL,
'ENABLE_AUTOMATIONS': request.app.state.config.ENABLE_AUTOMATIONS,
'ENABLE_CHANNELS': request.app.state.config.ENABLE_CHANNELS,
'ENABLE_CALENDAR': request.app.state.config.ENABLE_CALENDAR,
'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,

View file

@ -39,6 +39,11 @@ PAGE_ITEM_COUNT = 30
async def check_automations_permission(request, user):
if not request.app.state.config.ENABLE_AUTOMATIONS:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
if user.role != 'admin' and not await has_permission(
user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS
):
@ -158,7 +163,7 @@ async def create_new_automation(
):
await check_automations_permission(request, user)
try:
validate_rrule(form_data.data.rrule)
validate_rrule(form_data.data.rrule, tz=user.timezone)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -208,7 +213,7 @@ async def update_automation_by_id(
check_automation_access(automation, user)
try:
validate_rrule(form_data.data.rrule)
validate_rrule(form_data.data.rrule, tz=user.timezone)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -0,0 +1,389 @@
import logging
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.models.calendar import (
Calendars,
CalendarEvents,
CalendarEventAttendees,
CalendarForm,
CalendarUpdateForm,
CalendarEventForm,
CalendarEventUpdateForm,
CalendarModel,
CalendarEventModel,
CalendarEventUserResponse,
CalendarEventListResponse,
RSVPForm,
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
from open_webui.utils.auth import get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.calendar import expand_recurring_event
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
router = APIRouter()
SCHEDULED_TASKS_CALENDAR_ID = '__scheduled_tasks__'
async def check_calendar_permission(request: Request, user):
"""Check global feature flag AND per-user permission for calendar access."""
if not request.app.state.config.ENABLE_CALENDAR:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
if user.role != 'admin' and not await has_permission(
user.id, 'features.calendar', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
async def _user_has_automations(request: Request, user) -> bool:
"""Check if automations feature is available to this user."""
if not getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False):
return False
if user.role == 'admin':
return True
return await has_permission(user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS)
async def _check_calendar_access(calendar_id: str, user: UserModel, permission: str = 'write') -> CalendarModel:
"""Verify user has access to a calendar. Returns the calendar or raises 403/404."""
cal = await Calendars.get_calendar_by_id(calendar_id)
if not cal:
raise HTTPException(status_code=404, detail='Calendar not found')
if cal.user_id == user.id or user.role == 'admin':
return cal
user_groups = await Groups.get_groups_by_member_id(user.id)
user_group_ids = [g.id for g in user_groups]
if await AccessGrants.has_access(
user_id=user.id,
resource_type='calendar',
resource_id=cal.id,
permission=permission,
user_group_ids=user_group_ids,
):
return cal
raise HTTPException(status_code=403, detail='Access denied')
####################
# Calendar CRUD (static paths first)
####################
@router.get('/', response_model=list[CalendarModel])
async def get_calendars(request: Request, user: UserModel = Depends(get_verified_user)):
"""List user's calendars (owned + shared), plus a virtual Scheduled Tasks calendar
when automations are available."""
await check_calendar_permission(request, user)
calendars = await Calendars.get_calendars_by_user(user.id)
if await _user_has_automations(request, user):
now = int(time.time_ns())
calendars.append(
CalendarModel(
id=SCHEDULED_TASKS_CALENDAR_ID,
user_id=user.id,
name='Scheduled Tasks',
color='#8b5cf6',
is_default=False,
is_system=True,
created_at=now,
updated_at=now,
)
)
return calendars
@router.post('/create', response_model=CalendarModel)
async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)):
"""Create a new user calendar."""
await check_calendar_permission(request, user)
return await Calendars.insert_new_calendar(user.id, form_data)
####################
# Event CRUD (before /{calendar_id} to avoid route conflicts)
####################
@router.get('/events')
async def get_events(
request: Request,
start: str,
end: str,
calendar_ids: Optional[str] = None,
user: UserModel = Depends(get_verified_user),
):
"""Get events in date range.
Args:
start: ISO 8601 datetime string (e.g. 2026-04-01T00:00:00)
end: ISO 8601 datetime string (e.g. 2026-05-01T00:00:00)
calendar_ids: optional comma-separated list to filter
Includes:
- Stored events from the database
- Virtual events computed from active automation RRULEs (Scheduled Tasks calendar)
"""
await check_calendar_permission(request, user)
from datetime import datetime
try:
start_dt = datetime.fromisoformat(start.replace('Z', '+00:00'))
end_dt = datetime.fromisoformat(end.replace('Z', '+00:00'))
except ValueError:
raise HTTPException(status_code=400, detail='Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00)')
NS = 1_000_000
start_ns = int(start_dt.timestamp() * 1000) * NS
end_ns = int(end_dt.timestamp() * 1000) * NS
cal_id_list = calendar_ids.split(',') if calendar_ids else None
# 1. Stored events
events = await CalendarEvents.get_events_by_range(
user_id=user.id,
start=start_ns,
end=end_ns,
calendar_ids=cal_id_list,
)
# Expand recurring stored events
expanded = []
for event in events:
event_dict = event.model_dump()
if event_dict.get('rrule'):
instances = expand_recurring_event(event_dict, start_ns, end_ns, tz=user.timezone)
for inst in instances:
expanded.append(CalendarEventUserResponse(**{**inst, 'user': event.user}))
else:
expanded.append(event)
# 2. Virtual automation events (Scheduled Tasks calendar)
if await _user_has_automations(request, user) and (
cal_id_list is None or SCHEDULED_TASKS_CALENDAR_ID in cal_id_list
):
try:
from open_webui.models.automations import Automations, AutomationRuns
# Future runs: expand RRULEs for active automations only
active_automations = await Automations.get_active_by_user(user.id)
for auto in active_automations:
rrule_str = auto.data.get('rrule', '') if auto.data else ''
if not rrule_str:
continue
virtual = {
'id': f'auto_{auto.id}',
'calendar_id': SCHEDULED_TASKS_CALENDAR_ID,
'user_id': user.id,
'title': auto.name,
'description': auto.data.get('prompt', '') if auto.data else '',
'start_at': auto.next_run_at or 0,
'end_at': None,
'all_day': False,
'rrule': rrule_str,
'color': None,
'location': None,
'data': None,
'meta': {'automation_id': auto.id},
'is_cancelled': False,
'attendees': [],
'created_at': auto.created_at,
'updated_at': auto.updated_at,
'user': None,
}
# Only expand into the future — past runs are handled below
now_ns = int(time.time_ns())
rrule_start = max(start_ns, now_ns)
instances = expand_recurring_event(virtual, rrule_start, end_ns, tz=user.timezone)
for inst in instances:
expanded.append(CalendarEventUserResponse(**inst))
# Past runs: single range query joined with automation
runs_with_auto = await AutomationRuns.get_runs_by_user_range(user.id, start_ns, end_ns)
for run, auto in runs_with_auto:
expanded.append(
CalendarEventUserResponse(
id=f'run_{run.id}',
calendar_id=SCHEDULED_TASKS_CALENDAR_ID,
user_id=user.id,
title=auto.name,
description=run.error if run.status == 'error' else '',
start_at=run.created_at,
end_at=None,
all_day=False,
color=None,
location=None,
data=None,
meta={
'automation_id': auto.id,
'run_id': run.id,
'chat_id': run.chat_id,
'status': run.status,
},
is_cancelled=False,
attendees=[],
created_at=run.created_at,
updated_at=run.created_at,
user=None,
)
)
except Exception as e:
log.warning(f'Failed to compute automation events: {e}', exc_info=True)
return [e.model_dump() if hasattr(e, 'model_dump') else e for e in expanded]
@router.post('/events/create', response_model=CalendarEventModel)
async def create_event(request: Request, form_data: CalendarEventForm, user: UserModel = Depends(get_verified_user)):
await check_calendar_permission(request, user)
await _check_calendar_access(form_data.calendar_id, user, 'write')
return await CalendarEvents.insert_new_event(user.id, form_data)
@router.get('/events/search', response_model=CalendarEventListResponse)
async def search_events(
request: Request,
query: Optional[str] = None,
skip: int = 0,
limit: int = 30,
user: UserModel = Depends(get_verified_user),
):
await check_calendar_permission(request, user)
return await CalendarEvents.search_events(user_id=user.id, query=query, skip=skip, limit=limit)
@router.get('/events/{event_id}', response_model=CalendarEventModel)
async def get_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)):
await check_calendar_permission(request, user)
event = await CalendarEvents.get_event_by_id(event_id)
if not event:
raise HTTPException(status_code=404, detail='Event not found')
await _check_calendar_access(event.calendar_id, user, 'read')
return event
@router.post('/events/{event_id}/update', response_model=CalendarEventModel)
async def update_event(
request: Request, event_id: str, form_data: CalendarEventUpdateForm, user: UserModel = Depends(get_verified_user)
):
await check_calendar_permission(request, user)
event = await CalendarEvents.get_event_by_id(event_id)
if not event:
raise HTTPException(status_code=404, detail='Event not found')
await _check_calendar_access(event.calendar_id, user, 'write')
updated = await CalendarEvents.update_event_by_id(event_id, form_data)
if not updated:
raise HTTPException(status_code=500, detail='Failed to update')
return updated
@router.delete('/events/{event_id}/delete')
async def delete_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)):
await check_calendar_permission(request, user)
event = await CalendarEvents.get_event_by_id(event_id)
if not event:
raise HTTPException(status_code=404, detail='Event not found')
await _check_calendar_access(event.calendar_id, user, 'write')
result = await CalendarEvents.delete_event_by_id(event_id)
if not result:
raise HTTPException(status_code=500, detail='Failed to delete')
return {'status': True}
@router.post('/events/{event_id}/rsvp', response_model=dict)
async def rsvp_event(
request: Request, event_id: str, form_data: RSVPForm, user: UserModel = Depends(get_verified_user)
):
"""Update own RSVP status for an event."""
await check_calendar_permission(request, user)
if form_data.status not in ('accepted', 'declined', 'tentative', 'pending'):
raise HTTPException(status_code=400, detail='Invalid status')
result = await CalendarEventAttendees.update_rsvp(event_id, user.id, form_data.status)
if not result:
raise HTTPException(status_code=404, detail='Not an attendee of this event')
return {'status': True, 'rsvp': result.status}
####################
# Calendar by ID (dynamic path — MUST come after /events* routes)
####################
@router.get('/{calendar_id}', response_model=CalendarModel)
async def get_calendar_by_id(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)):
await check_calendar_permission(request, user)
cal = await _check_calendar_access(calendar_id, user, 'read')
return cal
@router.post('/{calendar_id}/update', response_model=CalendarModel)
async def update_calendar(
request: Request, calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user)
):
await check_calendar_permission(request, user)
cal = await _check_calendar_access(calendar_id, user, 'write')
# Only owner/admin can change access grants
if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
updated = await Calendars.update_calendar_by_id(calendar_id, form_data)
if not updated:
raise HTTPException(status_code=500, detail='Failed to update')
return updated
@router.delete('/{calendar_id}/delete')
async def delete_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)):
await check_calendar_permission(request, user)
# Block deletion of the virtual Scheduled Tasks calendar
if calendar_id == SCHEDULED_TASKS_CALENDAR_ID:
raise HTTPException(status_code=400, detail='System calendars cannot be deleted')
cal = await _check_calendar_access(calendar_id, user, 'write')
# Only owner/admin can delete
if cal.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=403, detail='Only owner can delete calendar')
# Block deletion of default calendar
if cal.is_default:
raise HTTPException(status_code=400, detail='Default calendar cannot be deleted')
result = await Calendars.delete_calendar_by_id(calendar_id)
if not result:
raise HTTPException(status_code=500, detail='Failed to delete')
return {'status': True}
@router.post('/{calendar_id}/default')
async def set_default_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)):
await check_calendar_permission(request, user)
cal = await Calendars.set_default_calendar(user.id, calendar_id)
if not cal:
raise HTTPException(status_code=404, detail='Calendar not found')
return cal

View file

@ -305,7 +305,7 @@ async def create_new_channel(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.access_grants = filter_allowed_access_grants(
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
@ -643,7 +643,7 @@ async def update_channel_by_id(
if channel.user_id != user.id and user.role != 'admin':
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
form_data.access_grants = filter_allowed_access_grants(
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
@ -923,15 +923,13 @@ async def model_response_handler(request, channel, message, user, db=None):
thread_history = []
images = []
message_users = {}
# Batch fetch all users in a single query (fixes N+1 problem)
user_ids = list({message.user_id for message in thread_messages})
message_users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)}
for thread_message in thread_messages:
message_user = None
if thread_message.user_id not in message_users:
message_user = await 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]
message_user = message_users.get(thread_message.user_id)
if thread_message.meta and thread_message.meta.get('model_id', None):
# If the message was sent by a model, use the model name

View file

@ -17,13 +17,14 @@ from open_webui.models.chats import (
ChatResponse,
Chats,
ChatTitleIdResponse,
SharedChatResponse,
ChatStatsExport,
AggregateChatStats,
ChatBody,
ChatHistoryStats,
MessageStats,
)
from open_webui.models.shared_chats import SharedChats, SharedChatResponse
from open_webui.models.access_grants import AccessGrants
from open_webui.models.tags import TagModel, Tags
from open_webui.models.folders import Folders
from open_webui.internal.db import get_async_session
@ -35,7 +36,7 @@ from pydantic import BaseModel
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_permission
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
log = logging.getLogger(__name__)
@ -807,7 +808,7 @@ async def get_shared_session_user_chat_list(
if direction:
filter['direction'] = direction
return await Chats.get_shared_chat_list_by_user_id(
return await SharedChats.get_by_user_id(
user.id,
filter=filter,
skip=skip,
@ -828,17 +829,32 @@ async def get_shared_chat_by_id(
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):
chat = await Chats.get_chat_by_share_id(share_id, db=db)
elif user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(share_id, db=db)
if chat:
return ChatResponse(**chat.model_dump())
else:
chat = await Chats.get_chat_by_share_id(share_id, db=db)
if not chat:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
# Look up the original chat_id to check access grants
shared = await SharedChats.get_by_id(share_id, db=db)
if shared:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
if not has_grant:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
return ChatResponse(**chat.model_dump())
############################
# GetChatsByTags
@ -878,11 +894,25 @@ async def get_user_chat_list_by_tag_name(
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
# Check if user has access via access grants (shared_chat grants)
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(id, db=db)
else:
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=id,
permission='read',
db=db,
)
if has_grant:
chat = await Chats.get_chat_by_id(id, db=db)
if 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)
############################
@ -948,7 +978,7 @@ async def update_chat_message_by_id(
event_emitter = await get_event_emitter(
{
'user_id': user.id,
'user_id': chat.user_id,
'chat_id': id,
'message_id': message_id,
},
@ -1002,7 +1032,7 @@ async def send_chat_message_event_by_id(
event_emitter = await get_event_emitter(
{
'user_id': user.id,
'user_id': chat.user_id,
'chat_id': id,
'message_id': message_id,
}
@ -1158,39 +1188,58 @@ async def clone_shared_chat_by_id(
else:
chat = await Chats.get_chat_by_share_id(id, db=db)
if chat:
updated_chat = {
**chat.chat,
'originalChatId': chat.id,
'branchPointMessageId': chat.chat['history']['currentId'],
'title': f'Clone of {chat.title}',
}
chats = await Chats.import_chats(
user.id,
[
ChatImportForm(
**{
'chat': updated_chat,
'meta': chat.meta,
'pinned': chat.pinned,
'folder_id': chat.folder_id,
}
)
],
db=db,
if not chat:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if chats:
chat = chats[0]
return ChatResponse(**chat.model_dump())
else:
# Enforce access grants
shared = await SharedChats.get_by_id(id, db=db)
if shared and user.role != 'admin':
has_grant = await AccessGrants.has_access(
user_id=user.id,
resource_type='shared_chat',
resource_id=shared.chat_id,
permission='read',
db=db,
)
if not has_grant:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
updated_chat = {
**chat.chat,
'originalChatId': chat.id,
'branchPointMessageId': chat.chat['history']['currentId'],
'title': f'Clone of {chat.title}',
}
chats = await Chats.import_chats(
user.id,
[
ChatImportForm(
**{
'chat': updated_chat,
'meta': chat.meta,
'pinned': chat.pinned,
'folder_id': chat.folder_id,
}
)
],
db=db,
)
if chats:
chat = chats[0]
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
)
############################
@ -1241,16 +1290,28 @@ async def share_chat_by_id(
if chat:
if chat.share_id:
shared_chat = await Chats.update_shared_chat_by_chat_id(chat.id, db=db)
return ChatResponse(**shared_chat.model_dump())
# Re-snapshot existing share
shared = await SharedChats.update(chat.share_id, db=db)
if shared:
# Re-fetch the original chat to return
chat = await Chats.get_chat_by_id(id, db=db)
return ChatResponse(**chat.model_dump())
shared_chat = await Chats.insert_shared_chat_by_chat_id(chat.id, db=db)
if not shared_chat:
# Create new share
shared = await SharedChats.create(id, user.id, db=db)
if not shared:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
)
return ChatResponse(**shared_chat.model_dump())
# Set share_id on the original chat
chat = await Chats.update_chat_share_id_by_id(id, shared.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=ERROR_MESSAGES.DEFAULT(),
)
return ChatResponse(**chat.model_dump())
else:
raise HTTPException(
@ -1260,7 +1321,7 @@ async def share_chat_by_id(
############################
# DeletedSharedChatById
# DeleteSharedChatById
############################
@ -1273,10 +1334,13 @@ async def delete_shared_chat_by_id(
if not chat.share_id:
return False
result = await Chats.delete_shared_chat_by_chat_id(id, db=db)
update_result = await Chats.update_chat_share_id_by_id(id, None, db=db)
await SharedChats.delete_by_chat_id(id, db=db)
await Chats.update_chat_share_id_by_id(id, None, db=db)
return result and update_result != None
# Revoke all access grants for this shared chat
await AccessGrants.set_access_grants('shared_chat', id, [], db=db)
return True
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -1284,6 +1348,85 @@ async def delete_shared_chat_by_id(
)
############################
# UpdateSharedChatAccessById
############################
class ChatAccessGrantsForm(BaseModel):
access_grants: list[dict]
@router.post('/shared/{id}/access/update', response_model=Optional[ChatResponse])
async def update_shared_chat_access_by_id(
request: Request,
id: str,
form_data: ChatAccessGrantsForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if chat.user_id != user.id and user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_chats',
)
await AccessGrants.set_access_grants('shared_chat', id, form_data.access_grants, db=db)
return ChatResponse(**chat.model_dump())
############################
# GetSharedChatAccessById
############################
@router.get('/shared/{id}/access', response_model=list)
async def get_shared_chat_access_by_id(
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if chat.user_id != user.id and user.role != 'admin':
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
grants = await AccessGrants.get_grants_by_resource('shared_chat', id, db=db)
return [
{
'id': g.id,
'principal_type': g.principal_type,
'principal_id': g.principal_id,
'permission': g.permission,
}
for g in grants
]
############################
# UpdateChatFolderIdById
############################

View file

@ -6,7 +6,7 @@ import aiohttp
from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.config import get_config, save_config, async_save_config
from open_webui.config import BannerModel
@ -293,7 +293,9 @@ async def verify_terminal_server_connection(
) as session:
# Orchestrators expose a policies API; plain terminals don't.
try:
async with session.get(f'{base_url}/api/v1/policies', headers=headers) as resp:
async with session.get(
f'{base_url}/api/v1/policies', headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.ok:
return {'status': True, 'type': 'orchestrator'}
except Exception:
@ -301,7 +303,9 @@ async def verify_terminal_server_connection(
# Fall back to open-terminal config endpoint.
try:
async with session.get(f'{base_url}/api/config', headers=headers) as resp:
async with session.get(
f'{base_url}/api/config', headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.ok:
return {'status': True, 'type': 'terminal'}
except Exception:
@ -342,7 +346,9 @@ async def put_terminal_server_policy(
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
) as session:
policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}'
async with session.put(policy_url, headers=headers, json=form_data.policy_data) as resp:
async with session.put(
policy_url, headers=headers, json=form_data.policy_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.ok:
return await resp.json()
detail = await resp.text()
@ -369,7 +375,9 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
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, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as oauth_server_metadata_response:
if oauth_server_metadata_response.status == 200:
try:
oauth_server_metadata = OAuthMetadata.model_validate(

View file

@ -6,7 +6,7 @@ import aiohttp
from pathlib import Path
from typing import Optional
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.models.functions import (
FunctionForm,
FunctionModel,
@ -117,7 +117,9 @@ async def load_function_from_url(request: Request, form_data: LoadUrlForm, user=
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'}, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.status != 200:
raise HTTPException(status_code=resp.status, detail='Failed to fetch the function')
data = await resp.text()

View file

@ -11,7 +11,7 @@ from typing import Optional
from urllib.parse import quote
import aiohttp
import requests
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
@ -52,32 +52,36 @@ IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
router = APIRouter()
def set_image_model(request: Request, model: str):
async def set_image_model(request: Request, model: str):
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']:
api_auth = get_automatic1111_api_auth(request)
try:
r = requests.get(
session = await get_session()
async with session.get(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': api_auth},
)
options = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
options = await r.json()
if model != options['sd_model_checkpoint']:
options['sd_model_checkpoint'] = model
r = requests.post(
async with session.post(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
json=options,
headers={'authorization': api_auth},
)
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
except Exception as e:
log.debug(f'{e}')
return request.app.state.config.IMAGE_GENERATION_MODEL
def get_image_model(request):
async def get_image_model(request):
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
return (
request.app.state.config.IMAGE_GENERATION_MODEL
@ -99,14 +103,15 @@ def get_image_model(request):
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
):
try:
r = requests.get(
session = await get_session()
async with session.get(
url=f'{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
headers={'authorization': get_automatic1111_api_auth(request)},
)
options = r.json()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
options = await r.json()
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))
@ -200,7 +205,7 @@ async def update_config(request: Request, form_data: ImagesConfig, user=Depends(
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)
await set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
if form_data.IMAGE_SIZE == 'auto' and not re.match(
IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
):
@ -324,7 +329,6 @@ async def verify_url(request: Request, user=Depends(get_admin_user)):
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':
headers = None
@ -340,7 +344,6 @@ async def verify_url(request: Request, user=Depends(get_admin_user)):
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)
else:
return True
@ -421,7 +424,6 @@ async def get_models(request: Request, user=Depends(get_verified_user)):
)
)
except Exception as e:
request.app.state.config.ENABLE_IMAGE_GENERATION = False
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
@ -437,21 +439,22 @@ class CreateImageForm(BaseModel):
GenerateImageForm = CreateImageForm # Alias for backward compatibility
def get_image_data(data: str, headers=None):
async def get_image_data(data: str, headers=None):
try:
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']
return r.content, mime_type
else:
log.error('Url does not point to an image.')
return None
session = await get_session()
async with session.get(
data,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
content_type = r.headers.get('content-type', '')
if content_type.split('/')[0] == 'image':
return await r.read(), content_type
else:
log.error('Url does not point to an image.')
return None, None
else:
if ',' in data:
header, encoded = data.split(',', 1)
@ -541,7 +544,7 @@ async def image_generations(
metadata = metadata or {}
model = get_image_model(request)
model = await get_image_model(request)
try:
if request.app.state.config.IMAGE_GENERATION_ENGINE == 'openai':
@ -595,12 +598,12 @@ async def image_generations(
for image in res['data']:
if image_url := image.get('url', None):
image_data, content_type = get_image_data(
image_data, content_type = await get_image_data(
image_url,
{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 = await get_image_data(image['b64_json'])
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
@ -645,14 +648,14 @@ async def image_generations(
if model.endswith(':predict'):
for image in res['predictions']:
image_data, content_type = get_image_data(image['bytesBase64Encoded'])
image_data, content_type = await get_image_data(image['bytesBase64Encoded'])
_, url = await 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'])
image_data, content_type = await get_image_data(part['inlineData']['data'])
_, url = await upload_image(
request,
image_data,
@ -705,7 +708,7 @@ async def image_generations(
if 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 = await get_image_data(image['url'], headers)
_, url = await upload_image(
request,
image_data,
@ -720,7 +723,7 @@ async def image_generations(
or request.app.state.config.IMAGE_GENERATION_ENGINE == ''
):
if form_data.model:
set_image_model(request, form_data.model)
await set_image_model(request, form_data.model)
data = {
'prompt': form_data.prompt,
@ -751,7 +754,7 @@ async def image_generations(
images = []
for image in res['images']:
image_data, content_type = get_image_data(image)
image_data, content_type = await get_image_data(image)
_, url = await upload_image(
request,
image_data,
@ -919,12 +922,12 @@ async def image_edits(
images = []
for image in res['data']:
if image_url := image.get('url', None):
image_data, content_type = get_image_data(
image_data, content_type = await get_image_data(
image_url,
{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 = await get_image_data(image['b64_json'])
_, url = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
images.append({'url': url})
@ -975,7 +978,7 @@ async def image_edits(
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'])
image_data, content_type = await get_image_data(part['inlineData']['data'])
_, url = await upload_image(
request,
image_data,
@ -1055,7 +1058,7 @@ async def image_edits(
if 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)
image_data, content_type = await get_image_data(image_url, headers)
_, url = await upload_image(
request,
image_data,

View file

@ -539,10 +539,10 @@ async def update_knowledge_access_by_id(
'sharing.public_knowledge',
)
await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
knowledge.access_grants = await AccessGrants.set_access_grants('knowledge', id, form_data.access_grants, db=db)
return KnowledgeFilesResponse(
**(await Knowledges.get_knowledge_by_id(id=id, db=db)).model_dump(),
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(id, db=db),
)
@ -826,7 +826,11 @@ async def remove_file_from_knowledge_by_id(
log.debug(e)
pass
if delete_file:
# Only the file owner or an admin may permanently delete the underlying
# file. Collaborators with KB write access can unlink a file from the
# knowledge base but must not be able to destroy files they do not own,
# as the same file may be referenced by other KBs and chats.
if delete_file and (file.user_id == user.id or user.role == 'admin'):
try:
# Remove the file's collection from vector database
file_collection = f'file-{form_data.file_id}'
@ -901,16 +905,7 @@ async def delete_knowledge_by_id(
if len(updated_knowledge) != len(knowledge_list):
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(
id=model.id,
name=model.name,
base_model_id=model.base_model_id,
meta=model.meta,
params=model.params,
access_grants=model.access_grants,
is_active=model.is_active,
)
model_form = ModelForm(**model.model_dump())
await Models.update_model_by_id(model.id, model_form, db=db)
# Clean up vector DB

View file

@ -144,6 +144,38 @@ async def query_memory(
limit=form_data.k,
)
# Filter results by relevance threshold to avoid returning unrelated
# memories. Vector similarity search always returns the top-K nearest
# neighbours even when they are completely irrelevant; applying the
# same RELEVANCE_THRESHOLD used by RAG ensures only genuinely matching
# memories are surfaced (distances are normalised to 0→1, higher is
# better).
relevance_threshold = getattr(request.app.state.config, 'RELEVANCE_THRESHOLD', 0.0)
if results and relevance_threshold > 0.0 and results.distances and results.distances[0]:
from open_webui.retrieval.vector.main import SearchResult
filtered_ids = []
filtered_docs = []
filtered_metas = []
filtered_dists = []
for idx, score in enumerate(results.distances[0]):
if score >= relevance_threshold:
if results.ids and results.ids[0]:
filtered_ids.append(results.ids[0][idx])
if results.documents and results.documents[0]:
filtered_docs.append(results.documents[0][idx])
if results.metadatas and results.metadatas[0]:
filtered_metas.append(results.metadatas[0][idx])
filtered_dists.append(score)
results = SearchResult(
ids=[filtered_ids] if filtered_ids else [[]],
documents=[filtered_docs] if filtered_docs else [[]],
metadatas=[filtered_metas] if filtered_metas else [[]],
distances=[filtered_dists] if filtered_dists else [[]],
)
return results

View file

@ -281,24 +281,71 @@ async def import_models(
model.id: model for model in (await Models.get_models_by_ids(model_ids, db=db) if model_ids else [])
}
# Batch-resolve write permissions in one query instead of
# per-model has_access calls (N+1 avoidance).
existing_model_ids = list(existing_models.keys())
if user.role != 'admin' and existing_model_ids:
groups = await Groups.get_groups_by_member_id(user.id, db=db)
user_group_ids = {group.id for group in groups}
writable_model_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type='model',
resource_ids=existing_model_ids,
permission='write',
user_group_ids=user_group_ids,
db=db,
)
else:
writable_model_ids = set(existing_model_ids)
for model_data in data:
# Here, you can add logic to validate model_data if needed
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:
# Enforce ownership/write-access before allowing overwrite
if (
user.role != 'admin'
and existing_model.user_id != user.id
and model_id not in writable_model_ids
):
log.warning(
'import_models: user %s skipped model %s (no write access)',
user.id,
model_id,
)
continue
# Update existing model
model_data['meta'] = model_data.get('meta', {})
model_data['params'] = model_data.get('params', {})
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
# Only filter access_grants when explicitly provided
# in the payload to avoid altering existing ACLs on
# metadata-only imports.
if 'access_grants' in model_data:
updated_model.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
updated_model.access_grants,
'sharing.public_models',
)
await 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', {})
new_model = ModelForm(**model_data)
new_model.access_grants = await filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
new_model.access_grants,
'sharing.public_models',
)
await Models.insert_new_model(user_id=user.id, form_data=new_model, db=db)
return True
else:
@ -384,8 +431,12 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user), db: AsyncSes
@router.get('/model/profile/image')
async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
model = await Models.get_model_by_id(id)
async def get_model_profile_image(
id: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
model = await Models.get_model_by_id(id, db=db)
if model:
etag = f'"{model.updated_at}"' if model.updated_at else None

View file

@ -1383,29 +1383,9 @@ async def generate_responses(
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 await Groups.get_groups_by_member_id(user.id)}
if not (
user.id == model_info.user_id
or await 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=ERROR_MESSAGES.MODEL_NOT_FOUND(),
)
await check_model_access(user, model_info)
else:
if user.role != 'admin':
raise HTTPException(
status_code=403,
detail=ERROR_MESSAGES.MODEL_NOT_FOUND(),
)
await check_model_access(user, None)
url, url_idx = await get_ollama_url(request, payload['model'], url_idx)
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(

View file

@ -1296,6 +1296,7 @@ async def embeddings(request: Request, form_data: dict, user):
headers=headers,
cookies=cookies,
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
if 'text/event-stream' in r.headers.get('Content-Type', ''):

View file

@ -94,9 +94,24 @@ 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'])
try:
res = await response.json() if 'application/json' in response.content_type else {}
if 'detail' in res:
raise HTTPException(
status_code=response.status,
detail=res['detail'],
)
except HTTPException:
raise
except Exception:
pass
raise HTTPException(
status_code=response.status,
detail=e.message,
)
except HTTPException:
raise
except Exception as e:
log.exception(f'Connection error: {e}')
@ -146,9 +161,21 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
try:
res = await response.json() if 'application/json' in response.content_type else {}
if 'detail' in res:
raise Exception(response.status, res)
raise HTTPException(
status_code=response.status,
detail=res['detail'],
)
except HTTPException:
raise
except Exception:
pass
raise HTTPException(
status_code=response.status,
detail=e.message,
)
except HTTPException:
raise
except Exception as e:
log.exception(f'Connection error: {e}')

View file

@ -48,7 +48,7 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
# Document loaders
from open_webui.retrieval.loaders.main import Loader
from open_webui.retrieval.loaders.youtube import YoutubeLoader
# Web search engines
@ -82,6 +82,8 @@ from open_webui.retrieval.web.yandex import search_yandex
from open_webui.retrieval.web.ydc import search_youcom
from open_webui.retrieval.utils import (
build_loader_from_config,
filter_accessible_collections,
get_content_from_url,
get_embedding_function,
get_reranking_function,
@ -489,6 +491,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
# Reranking settings
'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL,
'RAG_RERANKING_ENGINE': request.app.state.config.RAG_RERANKING_ENGINE,
'RAG_RERANKING_BATCH_SIZE': request.app.state.config.RAG_RERANKING_BATCH_SIZE,
'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,
@ -698,6 +701,7 @@ class ConfigForm(BaseModel):
# Reranking settings
RAG_RERANKING_MODEL: Optional[str] = None
RAG_RERANKING_ENGINE: Optional[str] = None
RAG_RERANKING_BATCH_SIZE: Optional[int] = None
RAG_EXTERNAL_RERANKER_URL: Optional[str] = None
RAG_EXTERNAL_RERANKER_API_KEY: Optional[str] = None
RAG_EXTERNAL_RERANKER_TIMEOUT: Optional[str] = None
@ -954,6 +958,12 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
else request.app.state.config.RAG_EXTERNAL_RERANKER_TIMEOUT
)
request.app.state.config.RAG_RERANKING_BATCH_SIZE = (
form_data.RAG_RERANKING_BATCH_SIZE
if form_data.RAG_RERANKING_BATCH_SIZE is not None
else request.app.state.config.RAG_RERANKING_BATCH_SIZE
)
log.info(
f'Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}'
)
@ -981,6 +991,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
request.app.state.config.RAG_RERANKING_ENGINE,
request.app.state.config.RAG_RERANKING_MODEL,
request.app.state.rf,
reranking_batch_size=request.app.state.config.RAG_RERANKING_BATCH_SIZE,
)
except Exception as e:
log.error(f'Error loading reranking model: {e}')
@ -1629,41 +1640,8 @@ async def process_file(
file_path = file.path
if file_path:
file_path = await asyncio.to_thread(Storage.get_file, file_path)
loader = Loader(
engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
user=user,
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,
PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
PDF_LOADER_MODE=request.app.state.config.PDF_LOADER_MODE,
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,
MISTRAL_OCR_USE_BASE64=request.app.state.config.MISTRAL_OCR_USE_BASE64,
MISTRAL_OCR_MODEL=request.app.state.config.MISTRAL_OCR_MODEL,
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,
)
loader = build_loader_from_config(request)
loader.user = user
docs = await loader.aload(file.filename, file.meta.get('content_type'), file_path)
docs = [
@ -1809,6 +1787,8 @@ async def process_text(
collection_name = form_data.collection_name
if collection_name is None:
collection_name = calculate_sha256_string(form_data.content)
else:
await _validate_collection_access([collection_name], user, access_type='write')
docs = [
Document(
@ -1850,6 +1830,8 @@ async def process_web(
collection_name = form_data.collection_name
if not collection_name:
collection_name = calculate_sha256_string(form_data.url)[:63]
else:
await _validate_collection_access([collection_name], user, access_type='write')
if not request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
await run_in_threadpool(
@ -2353,32 +2335,20 @@ async def process_web_search(request: Request, form_data: SearchForm, user=Depen
)
async def _validate_collection_access(collection_names: list[str], user) -> None:
async def _validate_collection_access(collection_names: list[str], user, access_type: str = 'read') -> None:
"""
Prevent users from querying collections they don't own.
Enforces ownership on user-memory-* and file-* collections.
Admins bypass this check.
Raise 403 if the user lacks access to any of the requested collections.
Delegates to the shared filter_accessible_collections utility so the
access rules stay in one place.
"""
if user.role == 'admin':
return
for name in collection_names:
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-') :]
if not await has_access_to_file(
file_id=file_id,
access_type='read',
user=user,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
requested = set(collection_names)
allowed = await filter_accessible_collections(requested, user, access_type=access_type)
denied = requested - allowed
if denied:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
class QueryDocForm(BaseModel):

View file

@ -16,6 +16,7 @@ from starlette.background import BackgroundTask
from open_webui.utils.auth import get_verified_user
from open_webui.utils.access_control import has_connection_access
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.models.groups import Groups
from open_webui.models.users import Users
@ -141,6 +142,7 @@ async def proxy_terminal(
headers=headers,
cookies=cookies,
data=body or None,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
)
upstream_content_type = upstream_response.headers.get('content-type', '')
@ -279,7 +281,7 @@ async def ws_terminal(
session = aiohttp.ClientSession()
try:
async with session.ws_connect(upstream_url) as upstream:
async with session.ws_connect(upstream_url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as upstream:
import asyncio
import json as _json

View file

@ -4,7 +4,7 @@ from typing import Optional
import time
import re
import aiohttp
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
from open_webui.models.groups import Groups
from pydantic import BaseModel, HttpUrl
from fastapi import APIRouter, Depends, HTTPException, Request, status
@ -274,7 +274,9 @@ async def load_tool_from_url(request: Request, form_data: LoadUrlForm, user=Depe
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'}, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.status != 200:
raise HTTPException(status_code=resp.status, detail='Failed to fetch the tool')
data = await resp.text()

View file

@ -14,7 +14,7 @@ from open_webui.models.auths import Auths
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.groups import Groups
from open_webui.models.chats import Chats
from open_webui.models.users import (
UserModel,
UserGroupIdsModel,
@ -276,14 +276,8 @@ async def update_default_user_permissions(request: Request, form_data: UserPermi
async def get_user_settings_by_session_user(
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
user = await Users.get_user_by_id(user.id, db=db)
if user:
return user.settings
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# user already fetched by get_verified_user — no need to refetch
return user.settings
############################
@ -339,14 +333,8 @@ async def get_user_status_by_session_user(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
user = await Users.get_user_by_id(user.id, db=db)
if user:
return user
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# user already fetched by get_verified_user — no need to refetch
return user
############################
@ -366,15 +354,14 @@ async def update_user_status_by_session_user(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
)
user = await Users.get_user_by_id(user.id, db=db)
if user:
user = await Users.update_user_status_by_id(user.id, form_data, db=db)
return user
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# user already fetched by get_verified_user — no need to refetch
updated = await Users.update_user_status_by_id(user.id, form_data, db=db)
if updated:
return updated
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
############################
@ -384,14 +371,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: AsyncSession = Depends(get_async_session)):
user = await Users.get_user_by_id(user.id, db=db)
if user:
return user.info
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# user already fetched by get_verified_user — no need to refetch
return user.info
############################
@ -403,19 +384,13 @@ async def get_user_info_by_session_user(user=Depends(get_verified_user), db: Asy
async def update_user_info_by_session_user(
form_data: dict, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
):
user = await Users.get_user_by_id(user.id, db=db)
if user:
if user.info is None:
user.info = {}
user = await Users.update_user_by_id(user.id, {'info': {**user.info, **form_data}}, db=db)
if user:
return user.info
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
# Merges against the auth-time snapshot of user.info. The previous pre-merge
# refetch only narrowed (did not eliminate) the lost-update window on concurrent
# same-user writes; real safety needs row locking or a version column.
existing_info = user.info or {}
updated = await Users.update_user_by_id(user.id, {'info': {**existing_info, **form_data}}, db=db)
if updated:
return updated.info
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -439,18 +414,6 @@ class UserActiveResponse(UserStatus):
@router.get('/{user_id}', response_model=UserActiveResponse)
async def get_user_by_id(user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_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-', '')
chat = await Chats.get_chat_by_id(chat_id)
if chat:
user_id = chat.user_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.USER_NOT_FOUND,
)
user = await Users.get_user_by_id(user_id, db=db)
if user:

View file

@ -697,6 +697,31 @@ async def yjs_document_update(sid, data):
log.warning(f'Session {sid} not in room {room}. Rejecting update.')
return
# Verify write permission — room membership only proves read access
user = SESSION_POOL.get(sid)
if not user:
return
if document_id.startswith('note:'):
note_id = document_id.split(':')[1]
note = await Notes.get_note_by_id(note_id)
if not note:
log.error(f'Note {note_id} not found')
return
if (
user.get('role') != 'admin'
and user.get('id') != note.user_id
and not await AccessGrants.has_access(
user_id=user.get('id'),
resource_type='note',
resource_id=note.id,
permission='write',
)
):
log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.')
return
try:
await stop_item_tasks(REDIS, document_id)
except Exception:
@ -724,10 +749,6 @@ async def yjs_document_update(sid, data):
skip_sid=sid,
)
user = SESSION_POOL.get(sid)
if not user:
return
async def debounced_save():
await asyncio.sleep(0.5)
await document_save_handler(document_id, data.get('data', {}), user)
@ -743,7 +764,7 @@ async def yjs_document_update(sid, data):
async def yjs_document_leave(sid, data):
"""Handle user leaving a document"""
try:
document_id = data['document_id']
document_id = normalize_document_id(data['document_id'])
user_id = data.get('user_id', sid)
log.info(f'User {user_id} leaving document {document_id}')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -56,19 +56,30 @@ async def get_current_timestamp(
"""
Get the current Unix timestamp in seconds.
:return: JSON with current_timestamp (seconds) and current_iso (ISO format)
:return: JSON with current_timestamp (seconds), current_iso (UTC ISO format), and user_local_iso (user's local time)
"""
try:
import datetime
from zoneinfo import ZoneInfo
now = datetime.datetime.now(datetime.timezone.utc)
return json.dumps(
{
'current_timestamp': int(now.timestamp()),
'current_iso': now.isoformat(),
},
ensure_ascii=False,
)
result = {
'current_timestamp': int(now.timestamp()),
'current_iso': now.isoformat(),
}
# Include the user's local time if timezone is available
tz_name = __user__.get('timezone') if __user__ else None
if tz_name:
try:
user_tz = ZoneInfo(tz_name)
user_now = now.astimezone(user_tz)
result['user_local_iso'] = user_now.isoformat()
result['user_timezone'] = tz_name
except Exception:
pass
return json.dumps(result, ensure_ascii=False)
except Exception as e:
log.exception(f'get_current_timestamp error: {e}')
return json.dumps({'error': str(e)})
@ -110,15 +121,27 @@ 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(),
},
ensure_ascii=False,
)
result = {
'current_timestamp': current_ts,
'current_iso': now.isoformat(),
'calculated_timestamp': adjusted_ts,
'calculated_iso': adjusted.isoformat(),
}
# Include the user's local time if timezone is available
tz_name = __user__.get('timezone') if __user__ else None
if tz_name:
try:
from zoneinfo import ZoneInfo
user_tz = ZoneInfo(tz_name)
result['user_local_iso'] = now.astimezone(user_tz).isoformat()
result['calculated_local_iso'] = adjusted.astimezone(user_tz).isoformat()
result['user_timezone'] = tz_name
except Exception:
pass
return json.dumps(result, ensure_ascii=False)
except ImportError:
# Fallback without dateutil
import datetime
@ -128,15 +151,26 @@ async def calculate_timestamp(
total_days = days_ago + (weeks_ago * 7) + (months_ago * 30) + (years_ago * 365)
adjusted = now - datetime.timedelta(days=total_days)
adjusted_ts = int(adjusted.timestamp())
return json.dumps(
{
'current_timestamp': current_ts,
'current_iso': now.isoformat(),
'calculated_timestamp': adjusted_ts,
'calculated_iso': adjusted.isoformat(),
},
ensure_ascii=False,
)
result = {
'current_timestamp': current_ts,
'current_iso': now.isoformat(),
'calculated_timestamp': adjusted_ts,
'calculated_iso': adjusted.isoformat(),
}
tz_name = __user__.get('timezone') if __user__ else None
if tz_name:
try:
from zoneinfo import ZoneInfo
user_tz = ZoneInfo(tz_name)
result['user_local_iso'] = now.astimezone(user_tz).isoformat()
result['calculated_local_iso'] = adjusted.astimezone(user_tz).isoformat()
result['user_timezone'] = tz_name
except Exception:
pass
return json.dumps(result, ensure_ascii=False)
except Exception as e:
log.exception(f'calculate_timestamp error: {e}')
return json.dumps({'error': str(e)})
@ -2494,7 +2528,6 @@ async def create_automation(
name: str,
prompt: str,
rrule: str,
model_id: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
__metadata__: dict = None,
@ -2502,6 +2535,7 @@ async def create_automation(
"""
Create a scheduled automation that runs a prompt on a recurring or one-time schedule.
Use this when the user wants to schedule a task to run automatically.
The automation will use the current chat model.
The rrule parameter must be a valid iCalendar RRULE string. Common examples:
- Every day at 9am: "DTSTART:20250101T090000\\nRRULE:FREQ=DAILY"
@ -2516,7 +2550,6 @@ async def create_automation(
:param name: A short descriptive name for the automation
:param prompt: The prompt/instructions to execute on each run
:param rrule: An iCalendar RRULE string defining the schedule
:param model_id: Optional model ID to use. Defaults to the current chat model if omitted.
:return: JSON with the created automation details including id, next scheduled runs
"""
if __request__ is None:
@ -2535,15 +2568,14 @@ async def create_automation(
if not user:
return json.dumps({'error': 'User not found'})
# Default to current chat's model if not specified
# Always use the calling model for the automation
model_id = (__metadata__ or {}).get('model_id')
if not model_id:
model_id = (__metadata__ or {}).get('model_id') or (__metadata__ or {}).get('model')
if not model_id:
return json.dumps({'error': 'model_id is required (could not detect current model)'})
return json.dumps({'error': 'Could not detect current model'})
# Validate the RRULE
try:
validate_rrule(rrule)
validate_rrule(rrule, tz=user.timezone)
except ValueError as e:
return json.dumps({'error': f'Invalid schedule: {e}'})
@ -2624,7 +2656,7 @@ async def update_automation(
# Validate RRULE if changed
if rrule is not None:
try:
validate_rrule(new_rrule)
validate_rrule(new_rrule, tz=user.timezone if user else None)
except ValueError as e:
return json.dumps({'error': f'Invalid schedule: {e}'})
@ -2812,3 +2844,424 @@ async def delete_automation(
except Exception as e:
log.exception(f'delete_automation error: {e}')
return json.dumps({'error': str(e)})
# =============================================================================
# CALENDAR TOOLS
# =============================================================================
def _get_user_tz(user_dict: dict):
"""Get the user's timezone as a ZoneInfo, falling back to UTC."""
from zoneinfo import ZoneInfo
tz_name = None
if user_dict:
tz_name = user_dict.get('timezone')
if tz_name:
try:
return ZoneInfo(tz_name)
except Exception:
pass
return ZoneInfo('UTC')
def _dt_to_ns(dt_str: str, tz) -> int:
"""Convert a datetime string to nanoseconds since epoch, interpreting in the given timezone."""
from datetime import datetime
dt = datetime.fromisoformat(dt_str)
# If naive (no timezone info), localize to user's timezone
if dt.tzinfo is None:
dt = dt.replace(tzinfo=tz)
return int(dt.timestamp() * 1_000) * 1_000_000
def _ns_to_dt(ns: int, tz) -> str:
"""Convert nanoseconds since epoch to a datetime string in the given timezone."""
from datetime import datetime
seconds = ns / 1_000_000_000
dt = datetime.fromtimestamp(seconds, tz=tz)
return dt.strftime('%Y-%m-%d %H:%M')
def _event_to_dict(event, tz) -> dict:
"""Convert a calendar event model to a human-friendly dict with local timestamps."""
return {
'id': event.id,
'calendar_id': event.calendar_id,
'title': event.title,
'description': event.description or '',
'start': _ns_to_dt(event.start_at, tz),
'end': _ns_to_dt(event.end_at, tz) if event.end_at else None,
'all_day': event.all_day,
'location': event.location or '',
'color': event.color,
'is_cancelled': event.is_cancelled,
}
async def search_calendar_events(
query: Optional[str] = None,
start: Optional[str] = None,
end: Optional[str] = None,
count: int = 10,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Search calendar events by text and/or date range.
Returns matching events across all accessible calendars.
:param query: Search text to match against event title, description, or location (optional)
:param start: Only return events starting at or after this datetime, e.g. "2026-04-20 00:00" (optional)
:param end: Only return events starting before this datetime, e.g. "2026-04-27 00:00" (optional)
:param count: Maximum number of events to return (default: 10)
:return: JSON list of matching events with id, title, description, start, end, calendar_id, location
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.calendar import CalendarEvents
user_id = __user__.get('id')
tz = _get_user_tz(__user__)
if isinstance(count, str):
try:
count = int(count)
except ValueError:
count = 10
if start or end:
# Date range query — use get_events_by_range
try:
start_ns = _dt_to_ns(start, tz) if start else 0
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid start datetime: {e}'})
try:
end_ns = (
_dt_to_ns(end, tz)
if end
else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000
)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid end datetime: {e}'})
items = await CalendarEvents.get_events_by_range(
user_id=user_id,
start=start_ns,
end=end_ns,
)
# Apply text filter if query is also provided
if query:
q = query.lower()
items = [
e
for e in items
if q in (e.title or '').lower()
or q in (e.description or '').lower()
or q in (e.location or '').lower()
]
events = [_event_to_dict(item, tz) for item in items[:count]]
return json.dumps(
{'events': events, 'total': len(items)},
ensure_ascii=False,
)
else:
# Text-only search
result = await CalendarEvents.search_events(
user_id=user_id,
query=query,
skip=0,
limit=count,
)
events = [_event_to_dict(item, tz) for item in result.items]
return json.dumps(
{'events': events, 'total': result.total},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'search_calendar_events error: {e}')
return json.dumps({'error': str(e)})
async def create_calendar_event(
title: str,
start: str,
end: Optional[str] = None,
description: Optional[str] = None,
calendar_id: Optional[str] = None,
all_day: bool = False,
location: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Create a new calendar event. If no calendar_id is provided, the event is
added to the user's default calendar.
:param title: Event title
:param start: Start datetime string in your local time (e.g. "2026-04-20 09:00" or "2026-04-20T09:00:00")
:param end: End datetime string in your local time (optional, omit for point-in-time events)
:param description: Event description (optional)
:param calendar_id: Target calendar ID (optional, uses default calendar if omitted)
:param all_day: Whether this is an all-day event (default: false)
:param location: Event location (optional)
:return: JSON with the created event details including id
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventForm
user_id = __user__.get('id')
# Resolve calendar_id: use provided, or fall back to default
if not calendar_id:
calendars = await Calendars.get_calendars_by_user(user_id)
default_cal = next((c for c in calendars if c.is_default), None)
if not default_cal and calendars:
default_cal = calendars[0]
if not default_cal:
return json.dumps({'error': 'No calendars found. Cannot create event.'})
calendar_id = default_cal.id
# Verify access
cal = await Calendars.get_calendar_by_id(calendar_id)
if not cal:
return json.dumps({'error': 'Calendar not found'})
if cal.user_id != user_id and __user__.get('role') != 'admin':
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',
resource_id=cal.id,
permission='write',
user_group_ids=set(user_group_ids),
):
return json.dumps({'error': 'Access denied to this calendar'})
# Coerce boolean from LLM
if isinstance(all_day, str):
all_day = all_day.lower() in ('true', '1', 'yes')
# Convert datetime strings to nanoseconds using user's timezone
tz = _get_user_tz(__user__)
try:
start_ns = _dt_to_ns(start, tz)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid start datetime: {e}. Use format like "2026-04-20 09:00"'})
end_ns = None
if end:
try:
end_ns = _dt_to_ns(end, tz)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid end datetime: {e}. Use format like "2026-04-20 10:00"'})
elif not all_day:
# Default to 1 hour duration
end_ns = start_ns + 3_600_000_000_000
form = CalendarEventForm(
calendar_id=calendar_id,
title=title,
description=description,
start_at=start_ns,
end_at=end_ns,
all_day=all_day,
location=location,
)
event = await CalendarEvents.insert_new_event(user_id, form)
if not event:
return json.dumps({'error': 'Failed to create event'})
return json.dumps(
{
'status': 'success',
**_event_to_dict(event, tz),
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'create_calendar_event error: {e}')
return json.dumps({'error': str(e)})
async def update_calendar_event(
event_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
start: Optional[str] = None,
end: Optional[str] = None,
all_day: Optional[bool] = None,
location: Optional[str] = None,
is_cancelled: Optional[bool] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Update an existing calendar event. Only provided fields are changed;
omitted fields stay the same.
:param event_id: The ID of the event to update
:param title: New event title (optional)
:param description: New event description (optional)
:param start: New start datetime string in your local time, e.g. "2026-04-20 09:00" (optional)
:param end: New end datetime string in your local time (optional)
:param all_day: Whether this is an all-day event (optional)
:param location: New event location (optional)
:param is_cancelled: Set to true to cancel the event (optional)
:return: JSON with the updated event details
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.calendar import Calendars, CalendarEvents, CalendarEventUpdateForm
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
user_id = __user__.get('id')
event = await CalendarEvents.get_event_by_id(event_id)
if not event:
return json.dumps({'error': 'Event not found'})
# Check write access to the event's calendar
cal = await Calendars.get_calendar_by_id(event.calendar_id)
if cal and cal.user_id != user_id and __user__.get('role') != 'admin':
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',
resource_id=cal.id,
permission='write',
user_group_ids=set(user_group_ids),
):
return json.dumps({'error': 'Access denied'})
# Coerce boolean strings from LLM
if isinstance(all_day, str):
all_day = all_day.lower() in ('true', '1', 'yes')
if isinstance(is_cancelled, str):
is_cancelled = is_cancelled.lower() in ('true', '1', 'yes')
# Convert datetime strings to nanoseconds using user's timezone
tz = _get_user_tz(__user__)
start_ns = None
if start is not None:
try:
start_ns = _dt_to_ns(start, tz)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid start datetime: {e}'})
end_ns = None
if end is not None:
try:
end_ns = _dt_to_ns(end, tz)
except (ValueError, TypeError) as e:
return json.dumps({'error': f'Invalid end datetime: {e}'})
form = CalendarEventUpdateForm(
title=title,
description=description,
start_at=start_ns,
end_at=end_ns,
all_day=all_day,
location=location,
is_cancelled=is_cancelled,
)
updated = await CalendarEvents.update_event_by_id(event_id, form)
if not updated:
return json.dumps({'error': 'Failed to update event'})
return json.dumps(
{
'status': 'success',
**_event_to_dict(updated, tz),
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'update_calendar_event error: {e}')
return json.dumps({'error': str(e)})
async def delete_calendar_event(
event_id: str,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Delete a calendar event permanently.
:param event_id: The ID of the event to delete
:return: JSON confirming the event was deleted
"""
if __request__ is None:
return json.dumps({'error': 'Request context not available'})
if not __user__:
return json.dumps({'error': 'User context not available'})
try:
from open_webui.models.calendar import Calendars, CalendarEvents
from open_webui.models.access_grants import AccessGrants
from open_webui.models.groups import Groups
user_id = __user__.get('id')
event = await CalendarEvents.get_event_by_id(event_id)
if not event:
return json.dumps({'error': 'Event not found'})
# Check write access
cal = await Calendars.get_calendar_by_id(event.calendar_id)
if cal and cal.user_id != user_id and __user__.get('role') != 'admin':
user_group_ids = [g.id for g in await Groups.get_groups_by_member_id(user_id)]
if not await AccessGrants.has_access(
user_id=user_id,
resource_type='calendar',
resource_id=cal.id,
permission='write',
user_group_ids=set(user_group_ids),
):
return json.dumps({'error': 'Access denied'})
title = event.title
result = await CalendarEvents.delete_event_by_id(event_id)
if not result:
return json.dumps({'error': 'Failed to delete event'})
return json.dumps(
{
'status': 'success',
'message': f'Event "{title}" deleted',
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'delete_calendar_event error: {e}')
return json.dumps({'error': str(e)})

View file

@ -257,6 +257,47 @@ async def filter_allowed_access_grants(
return access_grants
async def has_base_model_access(
user_id: str,
model_info,
*,
user_group_ids: set[str] | None = None,
db=None,
) -> bool:
"""
Walk the ``base_model_id`` chain and verify the caller has read access
at every hop.
Returns ``True`` when access is granted (or the chain ends at a raw
provider model that has no per-model ACL). Returns ``False`` the
moment a registered base model denies access.
"""
from open_webui.models.models import Models
from open_webui.models.access_grants import AccessGrants
base_model_id = getattr(model_info, 'base_model_id', None)
seen = {model_info.id}
while base_model_id and base_model_id not in seen:
seen.add(base_model_id)
base_model_info = await Models.get_model_by_id(base_model_id, db=db)
if base_model_info is None:
break # Raw provider model — no per-model ACL
if not (
user_id == base_model_info.user_id
or await AccessGrants.has_access(
user_id=user_id,
resource_type='model',
resource_id=base_model_info.id,
permission='read',
user_group_ids=user_group_ids,
db=db,
)
):
return False
base_model_id = getattr(base_model_info, 'base_model_id', None)
return True
async def check_model_access(
user: UserModel,
model_info,
@ -296,6 +337,10 @@ async def check_model_access(
)
):
raise HTTPException(status_code=403, detail='Model not found')
# Enforce access on chained base models
if not await has_base_model_access(user.id, model_info, user_group_ids=user_group_ids):
raise HTTPException(status_code=403, detail='Model not found')
else:
if user.role != 'admin':
raise HTTPException(status_code=403, detail='Model not found')

View file

@ -66,10 +66,18 @@ async def has_access_to_file(
return True
# Check if the file is associated with any chats the user has access to
# TODO: Granular access control for chats
chats = await Chats.get_shared_chats_by_file_id(file_id, db=db)
if chats:
return True
shared_chat_ids = await Chats.get_shared_chat_ids_by_file_id(file_id, db=db)
if shared_chat_ids:
accessible_ids = await AccessGrants.get_accessible_resource_ids(
user_id=user.id,
resource_type='shared_chat',
resource_ids=shared_chat_ids,
permission='read',
user_group_ids=user_group_ids,
db=db,
)
if accessible_ids:
return True
# Check if the file is directly attached to a shared workspace model
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):

View file

@ -1,11 +1,16 @@
"""
Automation utilities.
Automation utilities and unified scheduler.
RRULE helpers, worker loop, and execution logic.
RRULE helpers, scheduler worker loop, and execution logic.
Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
The scheduler_worker_loop handles all time-based background work:
- Automation execution (claim_due execute)
- Calendar event alerts (upcoming events socket + webhook notifications)
Environment:
AUTOMATION_POLL_INTERVAL seconds between polls (default: 10)
SCHEDULER_POLL_INTERVAL seconds between polls (default: 10)
CALENDAR_ALERT_LOOKAHEAD_MINUTES default alert window (default: 5)
"""
import asyncio
@ -31,7 +36,8 @@ from open_webui.internal.db import get_async_db
log = logging.getLogger(__name__)
AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10'))
SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10')))
CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10'))
####################
@ -39,6 +45,22 @@ AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10'))
####################
def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
"""Safely resolve a timezone string to ZoneInfo.
Returns None ( server-local fallback) when *tz* is empty, None,
or an unrecognised IANA key. Logs a warning on bad keys so
misconfiguration is visible in the server logs.
"""
if not tz:
return None
try:
return ZoneInfo(tz)
except (KeyError, Exception):
log.warning('Unknown timezone %r — falling back to server time', tz)
return None
def _parse_rule(s: str):
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
@ -55,38 +77,52 @@ def _parse_rule(s: str):
return rrulestr(s, ignoretz=True)
def validate_rrule(s: str) -> None:
"""Raise ValueError if the RRULE is malformed or exhausted."""
def validate_rrule(s: str, tz: str = None) -> None:
"""Raise ValueError if the RRULE is malformed or exhausted.
When *tz* is provided the "now" reference uses the user's local
clock so that near-future schedules are not incorrectly rejected
on servers whose system clock is ahead (e.g. UTC vs US timezones).
"""
try:
rule = _parse_rule(s)
except Exception as e:
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
if rule.after(datetime.now()) is None:
zi = _resolve_tz(tz)
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
if rule.after(now) is None:
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
def next_run_ns(s: str, tz: str = None) -> Optional[int]:
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
now = datetime.now(ZoneInfo(tz)) if tz else datetime.now()
zi = _resolve_tz(tz)
now = datetime.now(zi) if zi else datetime.now()
dt = _parse_rule(s).after(now.replace(tzinfo=None))
if dt is None:
return None
if tz:
dt = dt.replace(tzinfo=ZoneInfo(tz))
if zi:
dt = dt.replace(tzinfo=zi)
return int(dt.timestamp() * 1_000_000_000)
def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
"""Compute next N occurrences for UI preview."""
"""Compute next N occurrences for UI preview.
Uses the user's timezone for the starting "now" so that the
preview matches the user's local clock (same as next_run_ns).
"""
zi = _resolve_tz(tz)
rule = _parse_rule(s)
result = []
dt = datetime.now()
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
dt = now
for _ in range(n):
dt = rule.after(dt)
if not dt:
break
if tz:
dt_tz = dt.replace(tzinfo=ZoneInfo(tz))
if zi:
dt_tz = dt.replace(tzinfo=zi)
result.append(int(dt_tz.timestamp() * 1_000_000_000))
else:
result.append(int(dt.timestamp() * 1_000_000_000))
@ -117,26 +153,49 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
############################
# Keep the old name as an alias so any stale imports still work.
async def automation_worker_loop(app) -> None:
"""Poll for due automations, claim, fire-and-forget execute.
"""Deprecated alias — use scheduler_worker_loop."""
await scheduler_worker_loop(app)
async def scheduler_worker_loop(app) -> None:
"""Unified background scheduler for all time-based work.
Handles:
1. Automation execution (ENABLE_AUTOMATIONS)
2. Calendar event alerts (ENABLE_CALENDAR)
Runs on every instance. Poll interval is configurable via
AUTOMATION_POLL_INTERVAL env var (default: 10 seconds).
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
"""
log.info(f'Automation worker started (poll interval: {AUTOMATION_POLL_INTERVAL}s)')
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
while True:
try:
async with get_async_db() as db:
batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db)
if batch:
log.info(f'Claimed {len(batch)} due automation(s)')
for automation in batch:
asyncio.create_task(execute_automation(app, automation))
# ── Automations ──
if getattr(app.state.config, 'ENABLE_AUTOMATIONS', False):
try:
async with get_async_db() as db:
batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db)
if batch:
log.info(f'Claimed {len(batch)} due automation(s)')
for automation in batch:
asyncio.create_task(execute_automation(app, automation))
except Exception:
log.exception('Scheduler: automation error')
# ── Calendar Alerts ──
if getattr(app.state.config, 'ENABLE_CALENDAR', False):
try:
await _check_calendar_alerts(app)
except Exception:
log.exception('Scheduler: calendar alert error')
except Exception:
log.exception('Automation worker error')
log.exception('Scheduler worker error')
# Jitter to spread load across instances
await asyncio.sleep(AUTOMATION_POLL_INTERVAL + random.uniform(0, 2))
await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2))
##########################
@ -243,6 +302,7 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
handled correctly same path the frontend uses.
"""
import aiohttp
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
connections = getattr(getattr(app, 'state', None), 'config', None)
if connections is None:
@ -278,6 +338,7 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
target_url,
json={'path': cwd},
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as resp:
if resp.status != 200:
body = await resp.text()
@ -429,6 +490,98 @@ async def execute_automation(app, automation: AutomationModel) -> None:
####################
async def _check_calendar_alerts(app) -> None:
"""Check for upcoming calendar events and send alert notifications.
De-duplication is DB-backed via meta.alerted_at survives restarts
and works across multiple instances.
"""
from open_webui.models.calendar import CalendarEvents, CalendarEventUpdateForm
from open_webui.socket.main import sio
now_ns = int(time.time_ns())
default_lookahead_ns = CALENDAR_ALERT_LOOKAHEAD_MINUTES * 60 * 1_000_000_000
async with get_async_db() as db:
upcoming = await CalendarEvents.get_upcoming_events(now_ns, default_lookahead_ns, db=db)
if not upcoming:
return
for event, user_tz in upcoming:
# Skip if already alerted for this start time
if event.meta and event.meta.get('alerted_at'):
continue
# Compute minutes until event starts
minutes_until = max(0, int((event.start_at - now_ns) / (60 * 1_000_000_000)))
alert_data = {
'event_id': event.id,
'title': event.title,
'description': event.description or '',
'start_at': event.start_at,
'minutes_until': minutes_until,
'calendar_id': event.calendar_id,
'location': event.location or '',
}
await sio.emit(
'events',
{
'data': {
'type': 'calendar:alert',
'data': alert_data,
},
},
room=f'user:{event.user_id}',
)
# Mark as alerted in DB so it survives restarts / multi-instance
try:
await CalendarEvents.update_event_by_id(
event.id,
CalendarEventUpdateForm(meta={'alerted_at': now_ns}),
)
except Exception:
log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True)
# Send webhook notification if user has one configured
try:
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
enable_user_webhooks = getattr(app.state.config, 'ENABLE_USER_WEBHOOKS', False)
if enable_user_webhooks:
user = await Users.get_user_by_id(event.user_id)
if user and user.settings:
webhook_url = (
user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
if isinstance(user.settings, dict)
else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)(
'notifications', {}
).get('webhook_url', None)
if hasattr(user.settings, 'ui')
else None
)
if webhook_url:
from open_webui.utils.webhook import post_webhook
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
await post_webhook(
webui_name,
webhook_url,
f'{event.title} — starting {time_str}',
{
'action': 'calendar_alert',
'title': event.title,
'minutes_until': minutes_until,
'event_id': event.id,
},
)
except Exception:
log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True)
async def _record_run(
automation_id: str,
status: str,

View file

@ -0,0 +1,83 @@
"""
Calendar utilities.
RRULE expansion reusing the automation infra.
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
from zoneinfo import ZoneInfo
from open_webui.utils.automations import _parse_rule
log = logging.getLogger(__name__)
def expand_recurring_event(
event_dict: dict,
range_start_ns: int,
range_end_ns: int,
tz: Optional[str] = None,
max_instances: int = 5000,
) -> list[dict]:
"""Expand a recurring event into individual instances within a date range.
Takes an event dict (from CalendarEventModel.model_dump()) and produces
one dict per occurrence, with adjusted start_at / end_at.
"""
from dateutil.rrule import rrulestr
rrule_str = event_dict.get('rrule')
if not rrule_str:
return [event_dict]
range_start_dt = datetime.fromtimestamp(range_start_ns / 1_000_000_000)
range_end_dt = datetime.fromtimestamp(range_end_ns / 1_000_000_000)
scan_start = range_start_dt - timedelta(days=1)
try:
# Parse with dtstart near the range so we never iterate from epoch
rule = rrulestr(rrule_str, dtstart=scan_start, ignoretz=True)
except Exception:
log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}')
return [event_dict]
original_start_ns = event_dict['start_at']
original_end_ns = event_dict.get('end_at')
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
instances = []
dt = rule.after(scan_start, inc=True)
while dt and dt < range_end_dt and len(instances) < max_instances:
if tz:
try:
dt_tz = dt.replace(tzinfo=ZoneInfo(tz))
instance_start_ns = int(dt_tz.timestamp() * 1_000_000_000)
except Exception:
instance_start_ns = int(dt.timestamp() * 1_000_000_000)
else:
instance_start_ns = int(dt.timestamp() * 1_000_000_000)
if instance_start_ns >= range_start_ns:
instance = {
**event_dict,
'start_at': instance_start_ns,
'end_at': (instance_start_ns + duration_ns) if duration_ns else None,
'instance_id': f'{event_dict["id"]}_{instance_start_ns}',
}
instances.append(instance)
dt = rule.after(dt)
return instances
def ns_from_date(year: int, month: int, day: int, tz: Optional[str] = None) -> int:
"""Create epoch nanoseconds from a date."""
if tz:
dt = datetime(year, month, day, tzinfo=ZoneInfo(tz))
else:
dt = datetime(year, month, day)
return int(dt.timestamp() * 1_000_000_000)

View file

@ -10,7 +10,7 @@ import json
import uuid
import asyncio
from fastapi import Request, status
from fastapi import HTTPException, Request, status
from starlette.responses import Response, StreamingResponse, JSONResponse
@ -328,6 +328,8 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
try:
data = await process_pipeline_outlet_filter(request, data, user, models)
except HTTPException:
raise
except Exception as e:
raise Exception(f'Error: {e}')

View file

@ -26,12 +26,29 @@ import base64
import io
import re
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK
from open_webui.utils.session_pool import get_session
BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE)
# Extension-based MIME fallback, only used when ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK is True.
_IMAGE_MIME_FALLBACK = {
'.webp': 'image/webp',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.tif': 'image/tiff',
'.ico': 'image/x-icon',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.avif': 'image/avif',
}
async def get_image_base64_from_url(url: str) -> Optional[str]:
try:
@ -58,7 +75,11 @@ async def get_image_base64_from_url(url: str) -> Optional[str]:
if file_path.is_file():
with open(file_path, 'rb') as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
content_type, _ = mimetypes.guess_type(file_path.name)
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
if not content_type:
return None
return f'data:{content_type};base64,{encoded_string}'
else:
return None
@ -71,7 +92,7 @@ async def get_image_url_from_base64(request, base64_image_string, metadata, user
if BASE64_IMAGE_URL_PREFIX.match(base64_image_string):
image_url = ''
# Extract base64 image data from the line
image_data, content_type = get_image_data(base64_image_string)
image_data, content_type = await get_image_data(base64_image_string)
if image_data is not None:
_, image_url = await upload_image(
request,
@ -178,11 +199,13 @@ async def get_image_base64_from_file_id(id: str) -> Optional[str]:
# Check if the file already exists in the cache
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')
content_type, _ = mimetypes.guess_type(file_path.name)
content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
if not content_type:
return None
return f'data:{content_type};base64,{encoded_string}'
else:
return None

View file

@ -1,49 +1,51 @@
import asyncio
import json
import logging
import random
import requests
import aiohttp
import urllib.parse
import urllib.request
from typing import Optional
import websocket # NOTE: websocket-client (https://github.com/websocket-client/websocket-client)
import aiohttp
from pydantic import BaseModel
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
from open_webui.utils.session_pool import get_session
log = logging.getLogger(__name__)
default_headers = {'User-Agent': 'Mozilla/5.0'}
def queue_prompt(prompt, client_id, base_url, api_key):
async 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.debug(f'queue_prompt data: {p}')
try:
req = urllib.request.Request(
session = await get_session()
async with session.post(
f'{base_url}/prompt',
data=data,
json=p,
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
)
response = urllib.request.urlopen(req).read()
return json.loads(response)
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return await r.json()
except Exception as e:
log.exception(f'Error while queuing prompt: {e}')
raise e
raise
def get_image(filename, subfolder, folder_type, base_url, api_key):
async def get_image(filename, subfolder, folder_type, base_url, api_key):
log.info('get_image')
data = {'filename': filename, 'subfolder': subfolder, 'type': folder_type}
url_values = urllib.parse.urlencode(data)
req = urllib.request.Request(
session = await get_session()
async with session.get(
f'{base_url}/view?{url_values}',
headers={**default_headers, 'Authorization': f'Bearer {api_key}'},
)
with urllib.request.urlopen(req) as response:
return response.read()
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return await r.read()
def get_image_url(filename, subfolder, folder_type, base_url):
@ -53,32 +55,39 @@ def get_image_url(filename, subfolder, folder_type, base_url):
return f'{base_url}/view?{url_values}'
def get_history(prompt_id, base_url, api_key):
async def get_history(prompt_id, base_url, api_key):
log.info('get_history')
req = urllib.request.Request(
session = await get_session()
async with session.get(
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())
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as r:
r.raise_for_status()
return await r.json()
def get_images(ws, workflow, client_id, base_url, api_key):
prompt_id = queue_prompt(workflow, client_id, base_url, api_key)['prompt_id']
async def _ws_get_images(ws, workflow, client_id, base_url, api_key):
"""Queue a prompt and wait on *ws* for ComfyUI to finish executing it.
Returns a dict of ``{'data': [{'url': ...}, ...]}``.
"""
prompt_id = (await 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)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
message = json.loads(msg.data)
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
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
log.error(f'WebSocket closed unexpectedly: {msg.type}')
break
# binary messages (previews) are silently skipped
history = get_history(prompt_id, base_url, api_key)[prompt_id]
history = (await 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 [
@ -105,10 +114,10 @@ async def comfyui_upload_image(image_file_item, base_url, api_key):
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:
resp.raise_for_status()
return await resp.json()
session = await get_session()
async with session.post(url, data=form, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
resp.raise_for_status()
return await resp.json()
class ComfyUINodeInput(BaseModel):
@ -136,11 +145,9 @@ 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://')
workflow = json.loads(payload.workflow.workflow)
for node in payload.workflow.nodes:
def _apply_workflow_nodes(workflow, nodes, model, payload):
"""Mutate *workflow* dict in-place based on typed node definitions."""
for node in nodes:
if node.type:
if node.type == 'model':
for node_id in node.node_ids:
@ -151,6 +158,14 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie
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 == 'image':
if isinstance(payload.image, list):
for idx, node_id in enumerate(node.node_ids):
if idx < len(payload.image):
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 == 'width':
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key if node.key else 'width'] = payload.width
@ -171,24 +186,31 @@ async def comfyui_create_image(model: str, payload: ComfyUICreateImageForm, clie
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = node.value
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)
_apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload)
headers = {'Authorization': f'Bearer {api_key}'}
session = await get_session()
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.')
except Exception as e:
async with session.ws_connect(
f'{ws_url}/ws?clientId={client_id}',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as ws:
log.info('WebSocket connection established.')
log.info('Sending workflow to WebSocket server.')
log.info(f'Workflow: {workflow}')
images = await _ws_get_images(ws, workflow, client_id, base_url, api_key)
except aiohttp.WSServerHandshakeError as 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)
except Exception as e:
log.exception(f'Error while receiving images: {e}')
images = None
ws.close()
log.exception(f'Error during image generation: {e}')
return None
return images
@ -209,64 +231,26 @@ class ComfyUIEditImageForm(BaseModel):
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)
_apply_workflow_nodes(workflow, payload.workflow.nodes, model, payload)
for node in payload.workflow.nodes:
if node.type:
if node.type == 'model':
for node_id in node.node_ids:
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]
else:
for node_id in node.node_ids:
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':
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':
for node_id in node.node_ids:
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':
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':
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)
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = seed
else:
for node_id in node.node_ids:
workflow[node_id]['inputs'][node.key] = node.value
headers = {'Authorization': f'Bearer {api_key}'}
session = await get_session()
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.')
except Exception as e:
async with session.ws_connect(
f'{ws_url}/ws?clientId={client_id}',
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
) as ws:
log.info('WebSocket connection established.')
log.info('Sending workflow to WebSocket server.')
log.info(f'Workflow: {workflow}')
images = await _ws_get_images(ws, workflow, client_id, base_url, api_key)
except aiohttp.WSServerHandshakeError as 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)
except Exception as e:
log.exception(f'Error while receiving images: {e}')
images = None
ws.close()
log.exception(f'Error during image editing: {e}')
return None
return images

View file

@ -401,12 +401,65 @@ def is_opening_code_block(content):
return len(backtick_segments) > 1 and len(backtick_segments) % 2 == 0
_OPENAI_TOOL_DISPLAY_NAMES = {
'web_search_call': 'Web Search',
'file_search_call': 'File Search',
'computer_call': 'Computer Use',
}
def _render_openai_tool_call_handler(item: dict, done: bool) -> str:
"""Render an OpenAI Responses API server-side tool item as a <details> block.
Handles web_search_call, file_search_call, and computer_call items whose
schemas are defined in the openai-python SDK (generated from OpenAPI spec).
"""
item_type = item.get('type', '')
call_id = item.get('id', '')
display_name = _OPENAI_TOOL_DISPLAY_NAMES.get(item_type, item_type)
# Build a short summary of what the tool did
summary = ''
if item_type == 'web_search_call':
action = item.get('action', {})
if isinstance(action, dict):
atype = action.get('type', '')
if atype == 'search':
queries = action.get('queries') or []
query = action.get('query', '')
summary = (
f'Search: {", ".join(str(q) for q in queries)}'
if queries
else (f'Search: {query}' if query else '')
)
elif atype == 'open_page':
summary = f'Open page: {action.get("url", "")}' if action.get('url') else ''
elif atype == 'find_in_page':
summary = f'Find in page: {action.get("pattern", "")}' if action.get('pattern') else ''
elif item_type == 'file_search_call':
queries = item.get('queries', [])
if queries:
summary = f'Queries: {", ".join(str(q) for q in queries)}'
elif item_type == 'computer_call':
action = item.get('action')
actions = item.get('actions')
if isinstance(action, dict):
summary = f'Action: {action.get("type", "unknown")}'
elif isinstance(actions, list) and actions:
summary = f'Actions: {", ".join(a.get("type", "?") for a in actions if isinstance(a, dict))}'
escaped_name = html.escape(display_name)
if done:
return f'<details type="tool_calls" done="true" id="{call_id}" name="{escaped_name}" arguments="">\n<summary>Tool Executed</summary>\n{html.escape(summary)}\n</details>\n'
return f'<details type="tool_calls" done="false" id="{call_id}" name="{escaped_name}" arguments="">\n<summary>Executing...</summary>\n</details>\n'
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 = ''
parts: list[str] = []
# First pass: collect function_call_output items by call_id for lookup
tool_outputs = {}
@ -423,46 +476,52 @@ def serialize_output(output: list) -> str:
if 'text' in content_part:
text = content_part.get('text', '').strip()
if text:
content = f'{content}{text}\n'
parts.append(text)
elif item_type == 'function_call':
# Render tool call inline with its result (if available)
if content and not content.endswith('\n'):
content += '\n'
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 = ''
result_parts: list[str] = []
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
result_parts.append(str(output_text) if not isinstance(output_text, str) else output_text)
result_text = ''.join(result_parts)
files = result_item.get('files')
embeds = result_item.get('embeds', '')
content += f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n</details>\n'
parts.append(
f'<details type="tool_calls" done="true" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}" files="{html.escape(json.dumps(files)) if files else ""}" embeds="{html.escape(json.dumps(embeds))}">\n<summary>Tool Executed</summary>\n{html.escape(json.dumps(result_text, ensure_ascii=False))}\n</details>'
)
else:
content += f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>\n'
parts.append(
f'<details type="tool_calls" done="false" id="{call_id}" name="{name}" arguments="{html.escape(json.dumps(arguments))}">\n<summary>Executing...</summary>\n</details>'
)
elif item_type == 'function_call_output':
# Already handled inline with function_call above
pass
elif item_type in _OPENAI_TOOL_DISPLAY_NAMES:
status = item.get('status', 'in_progress')
done = status in ('completed', 'failed', 'incomplete') or idx != len(output) - 1
parts.append(_render_openai_tool_call_handler(item, done).rstrip('\n'))
elif item_type == 'reasoning':
reasoning_content = ''
reasoning_parts: list[str] = []
# Check for 'summary' (new structure) or 'content' (legacy/fallback)
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', '')
reasoning_parts.append(content_part.get('text', ''))
elif 'summary' in content_part: # Handle potential nested logic if any
pass
reasoning_content = reasoning_content.strip()
reasoning_content = ''.join(reasoning_parts).strip()
duration = item.get('duration')
status = item.get('status', 'in_progress')
@ -471,9 +530,6 @@ def serialize_output(output: list) -> str:
# 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'
display = html.escape(
'\n'.join(
(f'> {line}' if not line.startswith('>') else line) for line in reasoning_content.splitlines()
@ -481,19 +537,26 @@ def serialize_output(output: list) -> str:
)
if status == 'completed' or duration is not None or not is_last_item:
content = f'{content}<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>\n'
parts.append(
f'<details type="reasoning" done="true" duration="{duration or 0}">\n<summary>Thought for {duration or 0} seconds</summary>\n{display}\n</details>'
)
else:
content = f'{content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>\n'
parts.append(
f'<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{display}\n</details>'
)
elif item_type == 'open_webui:code_interpreter':
# Code interpreter needs to inspect/mutate prior accumulated content
# to strip trailing unclosed code fences — materialize only here.
content = '\n'.join(parts)
content_stripped, original_whitespace = split_content_and_whitespace(content)
if is_opening_code_block(content_stripped):
content = content_stripped.rstrip('`').rstrip() + original_whitespace
else:
content = content_stripped + original_whitespace
if content and not content.endswith('\n'):
content += '\n'
# Re-split back into parts list after mutation
parts = [content] if content else []
# Render the code_interpreter item as a <details> block
# so the frontend Collapsible renders "Analyzing..."/"Analyzed".
@ -519,11 +582,15 @@ def serialize_output(output: list) -> str:
output_attr = f' output="{html.escape(output_json)}"'
if status == 'completed' or duration is not None or not is_last_item:
content += f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>\n'
parts.append(
f'<details type="code_interpreter" done="true" duration="{duration or 0}"{output_attr}>\n<summary>Analyzed</summary>\n{display}\n</details>'
)
else:
content += f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>\n'
parts.append(
f'<details type="code_interpreter" done="false"{output_attr}>\n<summary>Analyzing…</summary>\n{display}\n</details>'
)
return content.strip()
return '\n'.join(parts).strip()
def deep_merge(target, source):
@ -2392,6 +2459,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
tool_ids = form_data.pop('tool_ids', None)
terminal_id = form_data.pop('terminal_id', None)
files = form_data.pop('files', None)
form_data.pop('folder_id', None)
# Caller-provided OpenAI-style tools take precedence over server-side
# tool resolution (tool_ids, MCP servers, builtin tools).
@ -2460,6 +2528,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
metadata = {
**metadata,
'model_id': form_data.get('model'),
'tool_ids': tool_ids,
'terminal_id': terminal_id,
'files': files,
@ -2618,7 +2687,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Resolve terminal tools if terminal_id is set (outside tool_ids check
# so system terminals work even when no other tools are selected)
if terminal_id:
terminal_capability = (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('terminal', True)
if terminal_id and terminal_capability:
try:
terminal_result = await get_terminal_tools(
request,
@ -2846,13 +2916,32 @@ def build_response_object(response, response_data):
async def get_system_oauth_token(request, user):
"""Get the system OAuth token for a user.
Primary path: use the oauth_session_id cookie (browser requests).
Fallback: look up the user's most recent OAuth session from the DB
(covers automations, API calls, and other cookie-less contexts).
"""
oauth_token = None
try:
if request.cookies.get('oauth_session_id', None):
oauth_session_id = request.cookies.get('oauth_session_id', None)
if oauth_session_id:
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
request.cookies.get('oauth_session_id', None),
oauth_session_id,
)
# Fallback: no cookie (automation, API key, etc.) — use most recent session
if oauth_token is None:
from open_webui.models.oauth_sessions import OAuthSessions
sessions = await OAuthSessions.get_sessions_by_user_id(user.id)
if sessions:
best = max(sessions, key=lambda s: s.updated_at)
oauth_token = await request.app.state.oauth_manager.get_oauth_token(
user.id,
best.id,
)
except Exception as e:
log.error(f'Error getting OAuth token: {e}')
return oauth_token
@ -3067,6 +3156,10 @@ async def outlet_filter_handler(ctx):
Replaces the separate POST /api/chat/completed round-trip.
Persists outlet-modified content to DB and emits a chat:outlet event
so the frontend can sync its in-memory state.
For temp chats (local: prefix), messages are built from form_data
plus the assistant response message stored in ctx['assistant_message'],
since temp chats have no DB-persisted history.
"""
request = ctx['request']
user = ctx['user']
@ -3078,17 +3171,45 @@ async def outlet_filter_handler(ctx):
chat_id = metadata.get('chat_id', '')
message_id = metadata.get('message_id')
if not chat_id or chat_id.startswith('local:') or not message_id:
if not chat_id or not message_id:
return
try:
messages_map = await Chats.get_messages_map_by_chat_id(chat_id)
if not messages_map:
return
is_temp_chat = chat_id.startswith('local:')
message_list = get_message_list(messages_map, message_id)
if not message_list:
return
try:
messages_map = None
if is_temp_chat:
# Temp chats have no DB record — build message list from
# the in-memory form_data plus the assistant response.
form_messages = ctx.get('form_data', {}).get('messages', [])
assistant_message = ctx.get('assistant_message', {})
message_list = [
{
'role': m.get('role'),
'content': m.get('content', ''),
}
for m in form_messages
]
# Append the full assistant message (content, output, usage, etc.)
if assistant_message:
message_list.append(
{
'id': message_id,
'role': 'assistant',
**assistant_message,
}
)
else:
messages_map = await Chats.get_messages_map_by_chat_id(chat_id)
if not messages_map:
return
message_list = get_message_list(messages_map, message_id)
if not message_list:
return
model_id = model.get('id') if isinstance(model, dict) else model
@ -3101,6 +3222,7 @@ async def outlet_filter_handler(ctx):
'content': m.get('content', ''),
'info': m.get('info'),
'timestamp': m.get('timestamp'),
**({'output': m['output']} if m.get('output') else {}),
**({'usage': m['usage']} if m.get('usage') else {}),
**({'sources': m['sources']} if m.get('sources') else {}),
}
@ -3141,20 +3263,22 @@ async def outlet_filter_handler(ctx):
)
# Persist outlet-modified content and notify frontend
# (skip DB persistence for temp chats — they have no DB record)
if outlet_result and outlet_result.get('messages'):
for msg in outlet_result['messages']:
msg_id = msg.get('id')
if msg_id and msg_id in messages_map:
original = messages_map[msg_id]
if original.get('content') != msg.get('content'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
msg_id,
{
'content': msg['content'],
'originalContent': original.get('content'),
},
)
if not is_temp_chat and messages_map:
for message in outlet_result['messages']:
outlet_message_id = message.get('id')
if outlet_message_id and outlet_message_id in messages_map:
original_message = messages_map[outlet_message_id]
if original_message.get('content') != message.get('content'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
outlet_message_id,
{
'content': message['content'],
'originalContent': original_message.get('content'),
},
)
if event_emitter:
await event_emitter(
@ -3278,7 +3402,7 @@ async def non_streaming_chat_response_handler(response, ctx):
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'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
@ -3288,6 +3412,11 @@ async def non_streaming_chat_response_handler(response, ctx):
)
await background_tasks_handler(ctx)
ctx['assistant_message'] = {
'content': content,
'output': response_output,
**({'usage': usage} if usage else {}),
}
await outlet_filter_handler(ctx)
response = build_response_object(response, merge_events_into_response(response_data, events))
@ -3713,6 +3842,40 @@ async def streaming_chat_response_handler(response, ctx):
elif data.get('type', '').startswith('response.'):
output, response_metadata = handle_responses_streaming_event(data, output)
# Emit citation sources from finalized output items
# (mirrors Chat Completions annotation handling at delta level)
if data.get('type') == 'response.output_item.done':
item = data.get('item', {})
if item.get('type') == 'message':
for part in item.get('content', []):
for annotation in part.get('annotations', []):
if annotation.get('type') == 'url_citation':
# Handle both flat (Responses API) and nested (Chat Completions) formats
url_citation = annotation.get('url_citation', annotation)
url = url_citation.get('url', '')
title = url_citation.get('title', url)
if url:
await event_emitter(
{
'type': 'source',
'data': {
'source': {
'name': title,
'url': url,
},
'document': [title],
'metadata': [
{
'source': url,
'name': title,
}
],
},
}
)
processed_data = {
'output': full_output(),
'content': serialize_output(full_output()),
@ -4783,7 +4946,7 @@ async def streaming_chat_response_handler(response, ctx):
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'{content}\n\n{title} - {request.app.state.config.WEBUI_URL}/c/{metadata["chat_id"]}',
{
'action': 'chat',
'message': content,
@ -4800,6 +4963,11 @@ async def streaming_chat_response_handler(response, ctx):
)
await background_tasks_handler(ctx)
ctx['assistant_message'] = {
'content': serialize_output(output),
'output': output,
**({'usage': usage} if usage else {}),
}
await outlet_filter_handler(ctx)
except asyncio.CancelledError:
log.warning('Task was cancelled!')

View file

@ -245,6 +245,12 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
start_tag = item.get('start_tag', '<think>')
end_tag = item.get('end_tag', '</think>')
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
# NOTE: Some providers (e.g. Moonshot/Kimi K2.5) require
# reasoning_content as a top-level field on assistant
# messages. This should be handled externally via a
# pipeline filter or connection-level middleware, not
# here — adding it universally breaks strict providers
# (OpenAI, Vertex AI, Azure) that reject unknown fields.
# else: skip reasoning blocks for normal LLM messages
elif item_type == 'open_webui:code_interpreter':

View file

@ -22,7 +22,7 @@ from open_webui.utils.plugin import (
load_function_module_by_id,
get_function_module_from_cache,
)
from open_webui.utils.access_control import has_access
from open_webui.utils.access_control import has_access, has_base_model_access
from open_webui.config import (
@ -283,11 +283,15 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
# Pre-warm the function module cache once per unique function ID.
# This ensures each function's DB freshness check runs exactly once,
# not once per (model × function) pair.
for function_id in all_function_ids:
# Only attempt to load functions that actually exist in the local DB;
# imported/custom model configs may reference tools or filters the user
# hasn't installed, and trying to load those would cause persistent
# "Failed to load function module" log spam on every model refresh.
for function_id in functions_by_id:
try:
await 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.debug(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
@ -404,6 +408,10 @@ async def check_model_access(user, model, db=None):
):
raise Exception('Model not found')
# Enforce access on chained base models
if not await has_base_model_access(user.id, model_info, db=db):
raise Exception('Model not found')
async def get_filtered_models(models, user, db=None):
# Filter out models that the user does not have access to

View file

@ -513,10 +513,12 @@ async def get_oauth_client_info_with_static_credentials(
log.error(f'Error parsing OAuth metadata from {url}: {e}')
continue
# Determine scope from server metadata if available
# Let the OAuth provider apply its default scopes.
# We intentionally do NOT join all scopes_supported here — that list
# represents every scope the server *can* grant, not what the client
# should request. Requesting all of them is almost always wrong and
# can break providers like Entra ID that require resource-specific scopes.
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'
@ -1920,10 +1922,10 @@ class OAuthManager:
users_to_logout.append(user)
if not users_to_logout and sid:
log.info(f'Back-channel logout: no user found by sub, sid-based lookup not yet supported (sid={sid})')
log.debug(f'Back-channel logout: no user found by sub, sid-based lookup not yet supported (sid={sid})')
if not users_to_logout:
log.info(f'Back-channel logout: no matching user for provider={matched_provider}, sub={sub}, sid={sid}')
log.debug(f'Back-channel logout: no matching user for provider={matched_provider}, sub={sub}, sid={sid}')
return JSONResponse(status_code=200, content={})
# 9. Revoke tokens and delete sessions

View file

@ -28,6 +28,10 @@ def set_security_headers() -> Dict[str, str]:
- x-frame-options
- x-permitted-cross-domain-policies
- content-security-policy
- content-security-policy-report-only
- cross-origin-embedder-policy
- cross-origin-opener-policy
- cross-origin-resource-policy
- reporting-endpoints
Each environment variable is associated with a specific setter function
@ -48,6 +52,10 @@ def set_security_headers() -> Dict[str, str]:
'XFRAME_OPTIONS': set_xframe,
'XPERMITTED_CROSS_DOMAIN_POLICIES': set_xpermitted_cross_domain_policies,
'CONTENT_SECURITY_POLICY': set_content_security_policy,
'CONTENT_SECURITY_POLICY_REPORT_ONLY': set_content_security_policy_report_only,
'CROSS_ORIGIN_EMBEDDER_POLICY': set_cross_origin_embedder_policy,
'CROSS_ORIGIN_OPENER_POLICY': set_cross_origin_opener_policy,
'CROSS_ORIGIN_RESOURCE_POLICY': set_cross_origin_resource_policy,
'REPORTING_ENDPOINTS': set_reporting_endpoints,
}
@ -135,6 +143,38 @@ def set_content_security_policy(value: str):
return {'Content-Security-Policy': value}
# Set Content-Security-Policy-Report-Only response header
def set_content_security_policy_report_only(value: str):
return {'Content-Security-Policy-Report-Only': value}
# Set Cross-Origin-Embedder-Policy response header
def set_cross_origin_embedder_policy(value: str):
pattern = r'^(unsafe-none|require-corp|credentialless)$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = 'require-corp'
return {'Cross-Origin-Embedder-Policy': value}
# Set Cross-Origin-Opener-Policy response header
def set_cross_origin_opener_policy(value: str):
pattern = r'^(unsafe-none|same-origin-allow-popups|same-origin)$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = 'same-origin'
return {'Cross-Origin-Opener-Policy': value}
# Set Cross-Origin-Resource-Policy response header
def set_cross_origin_resource_policy(value: str):
pattern = r'^(same-site|same-origin|cross-origin)$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
value = 'same-origin'
return {'Cross-Origin-Resource-Policy': value}
# Set Reporting-Endpoints response header
def set_reporting_endpoints(value: str):
return {'Reporting-Endpoints': value}

View file

@ -44,6 +44,7 @@ from open_webui.utils.plugin import load_tool_module_by_id
from open_webui.utils.access_control import has_access, has_connection_access
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_TIMEOUT,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
@ -93,6 +94,10 @@ from open_webui.tools.builtin import (
list_automations,
toggle_automation,
delete_automation,
search_calendar_events,
create_calendar_event,
update_calendar_event,
delete_calendar_event,
)
import copy
@ -547,11 +552,25 @@ async def get_builtin_tools(
builtin_functions.extend([create_tasks, update_task])
# Automation tools - create and manage scheduled automations from chat
if is_builtin_tool_enabled('automations') and await has_user_permission('automations'):
if (
is_builtin_tool_enabled('automations')
and getattr(request.app.state.config, 'ENABLE_AUTOMATIONS', False)
and await has_user_permission('automations')
):
builtin_functions.extend(
[create_automation, update_automation, list_automations, toggle_automation, delete_automation]
)
# Calendar tools - search/create/update/delete events
if (
is_builtin_tool_enabled('calendar')
and getattr(request.app.state.config, 'ENABLE_CALENDAR', False)
and await has_user_permission('calendar')
):
builtin_functions.extend(
[search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event]
)
for func in builtin_functions:
callable = await get_async_tool_function_and_apply_extra_params(
func,
@ -889,7 +908,9 @@ async def get_terminal_cwd(
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 {}, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get('cwd')
@ -916,7 +937,7 @@ async def get_terminal_system_prompt(
trust_env=True,
) as session:
# 1. Check feature flag
async with session.get(f'{base}/api/config') as resp:
async with session.get(f'{base}/api/config', ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
if resp.status != 200:
return None
config = await resp.json()
@ -924,7 +945,9 @@ async def get_terminal_system_prompt(
return None
# 2. Fetch system prompt
async with session.get(f'{base}/system', headers=headers, cookies=cookies or {}) as resp:
async with session.get(
f'{base}/system', headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.status == 200:
data = await resp.json()
return data.get('prompt')

View file

@ -3,7 +3,7 @@ import logging
import aiohttp
from open_webui.config import WEBUI_FAVICON_URL
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT, VERSION
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, VERSION
log = logging.getLogger(__name__)
@ -53,7 +53,7 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict) -> b
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:
async with session.post(url, json=payload, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
r_text = await r.text()
r.raise_for_status()
log.debug(f'r.text: {r_text}')

View file

@ -1,88 +0,0 @@
# Contributing to Open WebUI
🚀 **Welcome, Contributors!** 🚀
Your interest in contributing to Open WebUI is greatly appreciated. This document is here to guide you through the process, ensuring your contributions enhance the project effectively. Let's make Open WebUI even better, together!
## 📌 Key Points
### 🦙 Ollama vs. Open WebUI
It's crucial to distinguish between Ollama and Open WebUI:
- **Open WebUI** focuses on providing an intuitive and responsive web interface for chat interactions.
- **Ollama** is the underlying technology that powers these interactions.
If your issue or contribution pertains directly to the core Ollama technology, please direct it to the appropriate [Ollama project repository](https://ollama.com/). Open WebUI's repository is dedicated to the web interface aspect only.
### 🚨 Reporting Issues
Noticed something off? Have an idea? Check our [Issues tab](https://github.com/open-webui/open-webui/issues) to see if it's already been reported or suggested. If not, feel free to open a new issue. When reporting an issue, please follow our issue templates. These templates are designed to ensure that all necessary details are provided from the start, enabling us to address your concerns more efficiently.
> [!IMPORTANT]
>
> - **Template Compliance:** Please be aware that failure to follow the provided issue template, or not providing the requested information at all, will likely result in your issue being closed without further consideration. This approach is critical for maintaining the manageability and integrity of issue tracking.
> - **Detail is Key:** To ensure your issue is understood and can be effectively addressed, it's imperative to include comprehensive details. Descriptions should be clear, including steps to reproduce, expected outcomes, and actual results. Lack of sufficient detail may hinder our ability to resolve your issue.
> [!WARNING]
> Reporting vulnerabilities is not wanted through Issues!
> Instead, [use the security reporting functionality](https://github.com/open-webui/open-webui/security) and ensure you comply with the outlined requirements.
### 🧭 Scope of Support
We've noticed an uptick in issues not directly related to Open WebUI but rather to the environment it's run in, especially Docker setups. While we strive to support Docker deployment, understanding Docker fundamentals is crucial for a smooth experience.
- **Docker Deployment Support**: Open WebUI supports Docker deployment. Familiarity with Docker is assumed. For Docker basics, please refer to the [official Docker documentation](https://docs.docker.com/get-started/overview/).
- **Advanced Configurations**: Setting up reverse proxies for HTTPS and managing Docker deployments requires foundational knowledge. There are numerous online resources available to learn these skills. Ensuring you have this knowledge will greatly enhance your experience with Open WebUI and similar projects.
- **Check the documentation and help improve it**: [Our documentation](https://docs.openwebui.com) has ever growing troubleshooting guides and detailed installation tutorials. Please verify if it is of help to your issue and help expand it by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs).
## 💡 Contributing
Looking to contribute? Great! Here's how you can help:
### 🛠 Pull Requests
We welcome pull requests. Before submitting one, please:
1. Open a discussion regarding your ideas [here](https://github.com/open-webui/open-webui/discussions/new/choose).
2. Follow the project's coding standards and include tests for new features.
3. Update documentation as necessary.
4. Write clear, descriptive commit messages.
5. It's essential to complete your pull request in a timely manner. We move fast, and having PRs hang around too long is not feasible. If you can't get it done within a reasonable time frame, we may have to close it to keep the project moving forward.
> [!NOTE]
> The Pull Request Template has various requirements outlined. Go through the PR-checklist one by one and ensure you completed all steps before submitting your PR for review (you can open it as draft otherwise!).
### 📚 Documentation & Tutorials
Help us make Open WebUI more accessible by improving the documentation, writing tutorials, or creating guides on setting up and optimizing the Web UI.
Help expand our documentation by submitting issues and PRs on our [Docs Repository](https://github.com/open-webui/docs).
We welcome tutorials, guides and other documentation improvements!
### 🌐 Translations and Internationalization
Help us make Open WebUI available to a wider audience. In this section, we'll guide you through the process of adding new translations to the project.
We use JSON files to store translations. You can find the existing translation files in the `src/lib/i18n/locales` directory. Each directory corresponds to a specific language, for example, `en-US` for English (US), `fr-FR` for French (France) and so on. You can refer to [ISO 639 Language Codes](http://www.lingoes.net/en/translator/langcode.htm) to find the appropriate code for a specific language.
To add a new language:
- Create a new directory in the `src/lib/i18n/locales` path with the appropriate language code as its name. For instance, if you're adding translations for Spanish (Spain), create a new directory named `es-ES`.
- Copy the American English translation file(s) (from `en-US` directory in `src/lib/i18n/locale`) to this new directory and update the string values in JSON format according to your language. Make sure to preserve the structure of the JSON object.
- Add the language code and its respective title to languages file at `src/lib/i18n/locales/languages.json`.
> [!NOTE]
> When adding new translations, do so in a standalone PR! Feature PRs or PRs fixing a bug should not contain translation updates. Always keep the scope of a PR narrow.
### 🤔 Questions & Feedback
Got questions or feedback? Join our [Discord community](https://discord.gg/5rJgQTnV4s) or open an issue or discussion. We're here to help!
## 🙏 Thank You!
Your contributions, big or small, make a significant impact on Open WebUI. We're excited to see what you bring to the project!
Together, let's create an even more powerful tool for the community. 🌟

View file

@ -1,3 +0,0 @@
# Project workflow
[![](https://mermaid.ink/img/pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg?type=png)](https://mermaid.live/edit#pako:eNq1k01rAjEQhv_KkFNLFe1N9iAUevFSRVl6Cci4Gd1ANtlmsmtF_O_N7iqtHxR76ClhMu87zwyZvcicIpEIpo-KbEavGjceC2lL9EFnukQbIGXygNye5y9TY7DAZTpZLsjXXVYXg3dapRM4hh9mu5A7-3hTfSXtAtJK21Tsj8dPl3USmJZkGVbebWNKD2rNOjAYl6HJHYdkNBwNpb3U9aNZvzFNYE6h8tFiSyZzBUGJG4K1dwVwTSYQrCptlLRvLt5dA5i2la5Ruk51Ux0VKQjuxPVbAwuyiuFlNgHfzJ5DoxtgqQf1813gnZRLZ5lAYcD7WT1lpGtiQKug9C4jZrrp-Fd-1-Y1bdzo4dvnZDLz7lPHyj8sOgfg4x84E7RTuEaZt8yRZqtDfgT_rwG2u3Dv_ERPFOQL1Cqu2F5aAClCTgVJkcSrojVWJkgh7SGmYhXcYmczkQRfUU9UZfQ4baRI1miYDl_QqlPg)

View file

@ -1,205 +0,0 @@
# Hosting UI and Models separately
Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users:
# Open WebUI Configuration
## UI Configuration
For the UI configuration, you can set up the Apache VirtualHost as follows:
```
# Assuming you have a website hosting this UI at "server.com"
<VirtualHost 192.168.1.100:80>
ServerName server.com
DocumentRoot /home/server/public_html
ProxyPass / http://server.com:3000/ nocanon
ProxyPassReverse / http://server.com:3000/
# Needed after 0.5
ProxyPass / ws://server.com:3000/ nocanon
ProxyPassReverse / ws://server.com:3000/
</VirtualHost>
```
Enable the site first before you can request SSL:
`a2ensite server.com.conf` # this will enable the site. a2ensite is short for "Apache 2 Enable Site"
```
# For SSL
<VirtualHost 192.168.1.100:443>
ServerName server.com
DocumentRoot /home/server/public_html
ProxyPass / http://server.com:3000/ nocanon
ProxyPassReverse / http://server.com:3000/
# Needed after 0.5
ProxyPass / ws://server.com:3000/ nocanon
ProxyPassReverse / ws://server.com:3000/
SSLEngine on
SSLCertificateFile /etc/ssl/virtualmin/170514456861234/ssl.cert
SSLCertificateKeyFile /etc/ssl/virtualmin/170514456861234/ssl.key
SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
SSLProxyEngine on
SSLCACertificateFile /etc/ssl/virtualmin/170514456865864/ssl.ca
</VirtualHost>
```
I'm using virtualmin here for my SSL clusters, but you can also use certbot directly or your preferred SSL method. To use SSL:
### Prerequisites.
Run the following commands:
`snap install certbot --classic`
`snap apt install python3-certbot-apache` (this will install the apache plugin).
Navigate to the apache sites-available directory:
`cd /etc/apache2/sites-available/`
Create server.com.conf if it is not yet already created, containing the above `<virtualhost>` configuration (it should match your case. Modify as necessary). Use the one without the SSL:
Once it's created, run `certbot --apache -d server.com`, this will request and add/create an SSL keys for you as well as create the server.com.le-ssl.conf
# Configuring Ollama Server
On your latest installation of Ollama, make sure that you have setup your api server from the official Ollama reference:
[Ollama FAQ](https://github.com/jmorganca/ollama/blob/main/docs/faq.md)
### TL;DR
The guide doesn't seem to match the current updated service file on linux. So, we will address it here:
Unless when you're compiling Ollama from source, installing with the standard install `curl https://ollama.com/install.sh | sh` creates a file called `ollama.service` in /etc/systemd/system. You can use nano to edit the file:
```
sudo nano /etc/systemd/system/ollama.service
```
Add the following lines:
```
Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify
```
For instance:
```
[Unit]
Description=Ollama Service
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
Environment="OLLAMA_HOST=0.0.0.0:11434" # this line is mandatory. You can also specify 192.168.254.109:DIFFERENT_PORT, format
Environment="OLLAMA_ORIGINS=http://192.168.254.106:11434,https://models.server.city" # this line is optional
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/s>
[Install]
WantedBy=default.target
```
Save the file by pressing CTRL+S, then press CTRL+X
When your computer restarts, the Ollama server will now be listening on the IP:PORT you specified, in this case 0.0.0.0:11434, or 192.168.254.106:11434 (whatever your local IP address is). Make sure that your router is correctly configured to serve pages from that local IP by forwarding 11434 to your local IP server.
# Ollama Model Configuration
## For the Ollama model configuration, use the following Apache VirtualHost setup:
Navigate to the apache sites-available directory:
`cd /etc/apache2/sites-available/`
`nano models.server.city.conf` # match this with your ollama server domain
Add the following virtualhost containing this example (modify as needed):
```
# Assuming you have a website hosting this UI at "models.server.city"
<IfModule mod_ssl.c>
<VirtualHost 192.168.254.109:443>
DocumentRoot "/var/www/html/"
ServerName models.server.city
<Directory "/var/www/html/">
Options None
Require all granted
</Directory>
ProxyRequests Off
ProxyPreserveHost On
ProxyAddHeaders On
SSLProxyEngine on
ProxyPass / http://server.city:1000/ nocanon # or port 11434
ProxyPassReverse / http://server.city:1000/ # or port 11434
SSLCertificateFile /etc/letsencrypt/live/models.server.city/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/models.server.city/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
</VirtualHost>
</IfModule>
```
You may need to enable the site first (if you haven't done so yet) before you can request SSL:
`a2ensite models.server.city.conf`
#### For the SSL part of Ollama server
Run the following commands:
Navigate to the apache sites-available directory:
`cd /etc/apache2/sites-available/`
`certbot --apache -d server.com`
```
<VirtualHost 192.168.254.109:80>
DocumentRoot "/var/www/html/"
ServerName models.server.city
<Directory "/var/www/html/">
Options None
Require all granted
</Directory>
ProxyRequests Off
ProxyPreserveHost On
ProxyAddHeaders On
SSLProxyEngine on
ProxyPass / http://server.city:1000/ nocanon # or port 11434
ProxyPassReverse / http://server.city:1000/ # or port 11434
RewriteEngine on
RewriteCond %{SERVER_NAME} =models.server.city
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
```
Don't forget to restart/reload Apache with `systemctl reload apache2`
Open your site at https://server.com!
**Congratulations**, your _**Open-AI-like Chat-GPT style UI**_ is now serving AI with RAG, RBAC and multimodal features! Download Ollama models if you haven't yet done so!
If you encounter any misconfiguration or errors, please file an issue or engage with our discussion. There are a lot of friendly developers here to assist you.
Let's make this UI much more user friendly for everyone!
Thanks for making open-webui your UI Choice for AI!
This doc is made by **Bob Reyes**, your **Open-WebUI** fan from the Philippines.

211
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.8.12",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.8.12",
"version": "0.9.1",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@ -54,6 +54,7 @@
"dayjs": "^1.11.10",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
"fuse.js": "^7.0.0",
@ -214,42 +215,40 @@
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
"integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.1.2",
"@chevrotain/types": "11.1.2",
"lodash-es": "4.17.23"
"@chevrotain/gast": "12.0.0",
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz",
"integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.1.2",
"lodash-es": "4.17.23"
"@chevrotain/types": "12.0.0"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"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==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz",
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
"license": "Apache-2.0"
},
"node_modules/@codemirror/autocomplete": {
@ -1247,9 +1246,9 @@
"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",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1373,9 +1372,9 @@
"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",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -3583,9 +3582,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.55.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz",
"integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==",
"version": "2.57.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@ -3611,7 +3610,7 @@
"@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",
"typescript": "^5.3.3",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
@ -5389,9 +5388,9 @@
"license": "MIT"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -5619,9 +5618,9 @@
}
},
"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==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -5963,9 +5962,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -6368,29 +6367,31 @@
}
},
"node_modules/chevrotain": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
"license": "Apache-2.0",
"dependencies": {
"@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"
"@chevrotain/cst-dts-gen": "12.0.0",
"@chevrotain/gast": "12.0.0",
"@chevrotain/regexp-to-ast": "12.0.0",
"@chevrotain/types": "12.0.0",
"@chevrotain/utils": "12.0.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/chevrotain-allstar": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"chevrotain": "^11.0.0"
"chevrotain": "^12.0.0"
}
},
"node_modules/chokidar": {
@ -7743,9 +7744,9 @@
}
},
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@ -8238,9 +8239,9 @@
"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",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -8462,7 +8463,6 @@
"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,
"license": "MIT"
},
"node_modules/fast-fifo": {
@ -9051,9 +9051,9 @@
"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==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -10152,13 +10152,14 @@
}
},
"node_modules/langium": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz",
"integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==",
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.1.1",
"chevrotain-allstar": "~0.3.1",
"@chevrotain/regexp-to-ast": "~12.0.0",
"chevrotain": "~12.0.0",
"chevrotain-allstar": "~0.4.1",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.1.0"
@ -10600,16 +10601,16 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@ -10870,9 +10871,9 @@
"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",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -11103,9 +11104,9 @@
}
},
"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==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -11765,9 +11766,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
@ -11914,9 +11915,9 @@
}
},
"node_modules/postcss-load-config/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
"engines": {
@ -12326,9 +12327,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@ -12460,9 +12461,9 @@
"license": "MIT"
},
"node_modules/quick-temp/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": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -12742,9 +12743,9 @@
"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",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -15408,9 +15409,9 @@
}
},
"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==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@ -16152,9 +16153,9 @@
"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",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -16441,9 +16442,9 @@
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.8.12",
"version": "0.9.1",
"private": true,
"scripts": {
"dev": "npm run pyodide:fetch && vite dev --host",
@ -98,6 +98,7 @@
"dayjs": "^1.11.10",
"dompurify": "^3.2.6",
"eventsource-parser": "^1.1.2",
"fast-deep-equal": "^3.1.3",
"file-saver": "^2.0.5",
"focus-trap": "^7.6.4",
"fuse.js": "^7.0.0",

View file

@ -32,6 +32,8 @@ dependencies = [
"python-mimeparse==2.0.0",
"sqlalchemy==2.0.48",
"aiosqlite==0.21.0",
"asyncpg==0.30.0",
"alembic==1.18.4",
"peewee==3.19.0",
"peewee-migrate==1.14.3",

View file

@ -260,6 +260,17 @@ select {
display: none;
}
/* Hide leaked Mermaid temp containers if render cleanup misses.
Use visibility:hidden (not display:none) so mermaid can still
measure the SVG layout before extracting its HTML. */
body > div[id^='dmermaid-'],
body > iframe[id^='imermaid-'] {
position: fixed !important;
visibility: hidden !important;
height: 0 !important;
overflow: hidden !important;
}
.scrollbar-hidden:active::-webkit-scrollbar-thumb,
.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
.scrollbar-hidden:hover::-webkit-scrollbar-thumb {

View file

@ -0,0 +1,458 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type CalendarModel = {
id: string;
user_id: string;
name: string;
color: string | null;
is_default: boolean;
is_system: boolean;
data: Record<string, any> | null;
meta: Record<string, any> | null;
access_grants: any[];
created_at: number;
updated_at: number;
};
export type CalendarEventAttendeeModel = {
id: string;
event_id: string;
user_id: string;
status: string;
meta: Record<string, any> | null;
created_at: number;
updated_at: number;
};
export type CalendarEventModel = {
id: string;
calendar_id: string;
user_id: string;
title: string;
description: string | null;
start_at: number;
end_at: number | null;
all_day: boolean;
rrule: string | null;
color: string | null;
location: string | null;
data: Record<string, any> | null;
meta: Record<string, any> | null;
is_cancelled: boolean;
attendees: CalendarEventAttendeeModel[];
created_at: number;
updated_at: number;
// Set by expand_recurring_event for recurring instances
instance_id?: string;
};
export type CalendarEventForm = {
calendar_id: string;
title: string;
description?: string;
start_at: number;
end_at?: number;
all_day?: boolean;
rrule?: string;
color?: string;
location?: string;
data?: Record<string, any>;
meta?: Record<string, any>;
attendees?: { user_id: string; status?: string }[];
};
export type CalendarForm = {
name: string;
color?: string;
data?: Record<string, any>;
meta?: Record<string, any>;
access_grants?: { target_type: string; target_id: string; permission: string }[];
};
// ── Calendars ─────────────────────────────────
export const getCalendars = async (token: string): Promise<CalendarModel[]> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createCalendar = async (token: string, form: CalendarForm): Promise<CalendarModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateCalendar = async (
token: string,
calendarId: string,
form: Partial<CalendarForm>
): Promise<CalendarModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteCalendar = async (token: string, calendarId: string): Promise<boolean> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res?.status ?? false;
};
export const setDefaultCalendar = async (
token: string,
calendarId: string
): Promise<CalendarModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/default`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
// ── Events ─────────────────────────────────
export const getCalendarEvents = async (
token: string,
start: string,
end: string,
calendarIds?: string[]
): Promise<CalendarEventModel[]> => {
let error = null;
const params = new URLSearchParams();
params.append('start', start);
params.append('end', end);
if (calendarIds && calendarIds.length > 0) {
params.append('calendar_ids', calendarIds.join(','));
}
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events?${params.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createCalendarEvent = async (
token: string,
form: CalendarEventForm
): Promise<CalendarEventModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getCalendarEventById = async (
token: string,
eventId: string
): Promise<CalendarEventModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateCalendarEvent = async (
token: string,
eventId: string,
form: Partial<CalendarEventForm>
): Promise<CalendarEventModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteCalendarEvent = async (token: string, eventId: string): Promise<boolean> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res?.status ?? false;
};
export const rsvpCalendarEvent = async (
token: string,
eventId: string,
status: string
): Promise<{ status: boolean; rsvp: string }> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/rsvp`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ status })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const searchCalendarEvents = async (
token: string,
query: string | null,
skip: number = 0,
limit: number = 30
): Promise<{ items: CalendarEventModel[]; total: number }> => {
let error = null;
const params = new URLSearchParams();
if (query) params.append('query', query);
params.append('skip', skip.toString());
params.append('limit', limit.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/search?${params.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -953,6 +953,73 @@ export const deleteSharedChatById = async (token: string, id: string) => {
return res;
};
export const updateChatAccessGrants = async (token: string, id: string, accessGrants: object[]) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
access_grants: accessGrants
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getChatAccessGrants = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared/${id}/access`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateChatById = async (token: string, id: string, chat: object) => {
let error = null;

View file

@ -1212,6 +1212,23 @@
</div>
{/if}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Reranking Batch Size')}
</div>
<div class="">
<input
bind:value={RAGConfig.RAG_RERANKING_BATCH_SIZE}
type="number"
class=" bg-transparent text-center w-14 outline-none"
min="1"
max="16000"
step="1"
/>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">{$i18n.t('Top K')}</div>
<div class="flex items-center relative">

View file

@ -740,6 +740,14 @@
</div>
{/if}
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Memories')} ({$i18n.t('Beta')})
</div>
<Switch bind:state={adminConfig.ENABLE_MEMORIES} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes')} ({$i18n.t('Beta')})
@ -758,10 +766,18 @@
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Memories')} ({$i18n.t('Beta')})
{$i18n.t('Calendar')}
</div>
<Switch bind:state={adminConfig.ENABLE_MEMORIES} />
<Switch bind:state={adminConfig.ENABLE_CALENDAR} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Automations')}
</div>
<Switch bind:state={adminConfig.ENABLE_AUTOMATIONS} />
</div>
<div class="mb-2.5 flex w-full items-center justify-between pr-2">

View file

@ -916,6 +916,22 @@
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<div class="flex w-full justify-between my-1">
<div class=" self-center text-xs font-medium">
{$i18n.t('Calendar')}
</div>
<Switch bind:state={permissions.features.calendar} />
</div>
{#if defaultPermissions?.features?.calendar && !permissions.features.calendar}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30" />

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { CalendarEventModel } from '$lib/apis/calendar';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let event: CalendarEventModel;
export let calendarColor: string | null = null;
const dispatch = createEventDispatcher();
</script>
<Tooltip content="{event.title}{event.location ? ` · ${event.location}` : ''}">
<button
class="w-full text-left text-xs flex items-start gap-1.5 py-[1px] px-0.5 rounded-md
{event.meta?.automation_id ? 'opacity-60' : ''}
hover:bg-gray-50 dark:hover:bg-gray-800/50 transition truncate"
on:click|stopPropagation={() => dispatch('click', event)}
>
<span
class="shrink-0 size-[7px] rounded-full mt-[5px]"
style="background-color: {event.color || calendarColor || '#3b82f6'};"
></span>
<span class="truncate">
{#if !event.all_day}<span class="text-gray-500 dark:text-gray-400"
>{new Date(event.start_at / 1_000_000)
.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
.replace(' ', '')}</span
>{/if}
{event.title}
</span>
</button>
</Tooltip>

View file

@ -0,0 +1,295 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Modal from '$lib/components/common/Modal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import type { CalendarModel, CalendarEventModel, CalendarEventForm } from '$lib/apis/calendar';
import {
createCalendarEvent,
updateCalendarEvent,
deleteCalendarEvent
} from '$lib/apis/calendar';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let show = false;
export let event: CalendarEventModel | null = null;
export let calendars: CalendarModel[] = [];
export let defaultCalendarId: string = '';
export let defaultStartAt: number | null = null;
let title = '';
let description = '';
let calendarId = '';
let startDate = '';
let startTime = '';
let endDate = '';
let endTime = '';
let allDay = false;
let location = '';
let alertMinutes: number = 10;
let loading = false;
let showDeleteConfirmDialog = false;
const NS = 1_000_000;
function nsToDateStr(ns: number): string {
return new Date(ns / NS).toISOString().slice(0, 10);
}
function nsToTimeStr(ns: number): string {
return new Date(ns / NS).toTimeString().slice(0, 5);
}
function dateTimeToNs(dateStr: string, timeStr: string): number {
return new Date(`${dateStr}T${timeStr || '00:00'}`).getTime() * NS;
}
function reset() {
if (event) {
title = event.title;
description = event.description || '';
calendarId = event.calendar_id;
startDate = nsToDateStr(event.start_at);
startTime = nsToTimeStr(event.start_at);
endDate = event.end_at ? nsToDateStr(event.end_at) : '';
endTime = event.end_at ? nsToTimeStr(event.end_at) : '';
allDay = event.all_day;
location = event.location || '';
alertMinutes = event.meta?.alert_minutes ?? 10;
} else {
title = '';
description = '';
calendarId = defaultCalendarId || (calendars.length > 0 ? calendars[0].id : '');
if (defaultStartAt) {
startDate = nsToDateStr(defaultStartAt);
startTime = nsToTimeStr(defaultStartAt);
const endNs = defaultStartAt + 60 * 60 * 1000 * NS;
endDate = nsToDateStr(endNs);
endTime = nsToTimeStr(endNs);
} else {
const now = new Date();
startDate = now.toISOString().slice(0, 10);
startTime = now.toTimeString().slice(0, 5);
const later = new Date(now.getTime() + 60 * 60 * 1000);
endDate = later.toISOString().slice(0, 10);
endTime = later.toTimeString().slice(0, 5);
}
allDay = false;
location = '';
alertMinutes = 10;
}
}
$: if (show) reset();
const submitHandler = async () => {
if (!title.trim()) {
toast.error($i18n.t('Title is required'));
return;
}
loading = true;
try {
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
const endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
if (event && !event.meta?.automation_id) {
const result = await updateCalendarEvent(localStorage.token, event.id, {
calendar_id: calendarId,
title: title.trim(),
description: description.trim() || undefined,
start_at: startNs,
end_at: endNs,
all_day: allDay,
location: location.trim() || undefined,
meta: { alert_minutes: alertMinutes }
});
if (result) {
toast.success($i18n.t('Event updated'));
dispatch('save', result);
show = false;
}
} else {
const form: CalendarEventForm = {
calendar_id: calendarId,
title: title.trim(),
description: description.trim() || undefined,
start_at: startNs,
end_at: endNs,
all_day: allDay,
location: location.trim() || undefined,
meta: { alert_minutes: alertMinutes }
};
const result = await createCalendarEvent(localStorage.token, form);
if (result) {
toast.success($i18n.t('Event created'));
dispatch('save', result);
show = false;
}
}
} catch (err) {
toast.error(`${err}`);
} finally {
loading = false;
}
};
const deleteHandler = async () => {
if (!event || event.meta?.automation_id) return;
loading = true;
try {
await deleteCalendarEvent(localStorage.token, event.id);
toast.success($i18n.t('Event deleted'));
dispatch('delete', event);
show = false;
} catch (err) {
toast.error(`${err}`);
} finally {
loading = false;
}
};
</script>
<Modal size="md" bind:show>
<div>
<!-- Header -->
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<input
class="w-full text-lg bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
type="text"
bind:value={title}
placeholder={$i18n.t('Event title')}
/>
<button
class="self-center shrink-0 ml-2"
aria-label={$i18n.t('Close')}
on:click={() => (show = false)}
>
<XMark className="size-5" />
</button>
</div>
<!-- Details -->
<div class="px-5 pb-2 flex flex-col gap-3">
<!-- Calendar -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Calendar')}</div>
<select
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={calendarId}
>
{#each calendars.filter((c) => c.id !== '__scheduled_tasks__') as cal (cal.id)}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
</div>
<!-- Date / Time -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('When')}</div>
<div class="flex items-center gap-2 text-sm flex-wrap">
<input type="date" class="bg-transparent outline-hidden" bind:value={startDate} />
{#if !allDay}
<input type="time" class="bg-transparent outline-hidden" bind:value={startTime} />
<span class="text-gray-300 dark:text-gray-600"></span>
<input type="time" class="bg-transparent outline-hidden" bind:value={endTime} />
{/if}
<label class="flex items-center gap-1.5 cursor-pointer text-xs text-gray-400 ml-auto">
<input type="checkbox" class="accent-blue-500" bind:checked={allDay} />
{$i18n.t('All day')}
</label>
</div>
</div>
<!-- Location -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Location')}</div>
<input
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
placeholder={$i18n.t('Add location')}
bind:value={location}
/>
</div>
<!-- Reminder -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Reminder')}</div>
<select
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={alertMinutes}
>
<option value={-1}>{$i18n.t('None')}</option>
<option value={0}>{$i18n.t('At time of event')}</option>
<option value={5}>{$i18n.t('5 minutes before')}</option>
<option value={10}>{$i18n.t('10 minutes before')}</option>
<option value={15}>{$i18n.t('15 minutes before')}</option>
<option value={30}>{$i18n.t('30 minutes before')}</option>
<option value={60}>{$i18n.t('1 hour before')}</option>
</select>
</div>
<!-- Description -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<textarea
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 resize-none min-h-[4rem]"
placeholder={$i18n.t('Add description')}
bind:value={description}
rows="3"
></textarea>
</div>
</div>
<!-- Bottom toolbar -->
<div class="flex items-center justify-between px-4 pb-3.5 pt-1 gap-2">
<div class="flex items-center gap-0.5 flex-1 min-w-0">
{#if event && !event.meta?.automation_id}
<button
class="px-3 py-1 text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
type="button"
on:click={() => (showDeleteConfirmDialog = true)}
disabled={loading}
>
{$i18n.t('Delete')}
</button>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 transition"
type="button"
on:click={() => (show = false)}
>
{$i18n.t('Cancel')}
</button>
<button
class="px-3.5 py-1.5 text-sm bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
? 'cursor-not-allowed'
: ''}"
on:click={submitHandler}
type="button"
disabled={loading}
>
{event && !event.meta?.automation_id ? $i18n.t('Save') : $i18n.t('Create')}
{#if loading}
<span class="shrink-0"><Spinner /></span>
{/if}
</button>
</div>
</div>
</div>
</Modal>
<DeleteConfirmDialog
bind:show={showDeleteConfirmDialog}
title={$i18n.t('Delete Event')}
message={$i18n.t('This action cannot be undone. Do you wish to continue?')}
on:confirm={deleteHandler}
/>

View file

@ -0,0 +1,233 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { CalendarModel } from '$lib/apis/calendar';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
const i18n = getContext('i18n');
export let calendars: CalendarModel[] = [];
export let visibleCalendarIds: Set<string> = new Set();
export let currentDate: Date = new Date();
export let onToggle: (id: string) => void = () => {};
export let onCreateCalendar: () => void = () => {};
export let onDeleteCalendar: (id: string) => void = () => {};
export let onDateSelect: (date: Date) => void = () => {};
// Delete confirmation state
let showDeleteConfirm = false;
let deleteTargetCalendar: CalendarModel | null = null;
function isDeletable(cal: CalendarModel): boolean {
return !cal.is_default && !cal.is_system;
}
function handleDeleteClick(e: MouseEvent, cal: CalendarModel) {
e.stopPropagation();
deleteTargetCalendar = cal;
showDeleteConfirm = true;
}
function confirmDelete() {
if (deleteTargetCalendar) {
onDeleteCalendar(deleteTargetCalendar.id);
}
deleteTargetCalendar = null;
}
// Mini calendar state
$: miniMonth = currentDate.getMonth();
$: miniYear = currentDate.getFullYear();
$: miniMonthStart = new Date(miniYear, miniMonth, 1);
$: miniCalStart = (() => {
const d = new Date(miniMonthStart);
d.setDate(d.getDate() - d.getDay());
return d;
})();
$: miniDays = (() => {
const days: Date[] = [];
const d = new Date(miniCalStart);
for (let i = 0; i < 42; i++) {
days.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return days;
})();
$: miniMonthNames = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
function isToday(d: Date): boolean {
return d.toDateString() === new Date().toDateString();
}
function isSelected(d: Date): boolean {
return d.toDateString() === currentDate.toDateString();
}
function navigateMini(delta: number) {
if (miniMonth + delta > 11) {
miniMonth = 0;
miniYear++;
} else if (miniMonth + delta < 0) {
miniMonth = 11;
miniYear--;
} else {
miniMonth += delta;
}
}
</script>
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete Calendar')}
message={$i18n.t(
'This will permanently delete the calendar "{{name}}" and all its events. This action cannot be undone.',
{ name: deleteTargetCalendar?.name ?? '' }
)}
confirmLabel={$i18n.t('Delete')}
onConfirm={confirmDelete}
/>
<div class="flex flex-col gap-4">
<!-- Mini Month Calendar -->
<div>
<div class="flex items-center justify-between px-1 mb-1.5 mt-1.5">
<div class="text-[11px] font-medium">{miniMonthNames[miniMonth]} {miniYear}</div>
<div class="flex items-center gap-0.5">
<button
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => navigateMini(-1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/></svg
>
</button>
<button
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => navigateMini(1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/></svg
>
</button>
</div>
</div>
<div class="grid grid-cols-7 text-center text-[9px] text-gray-400 dark:text-gray-500 mb-0.5">
{#each ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as d}
<div class="py-0.5">{d}</div>
{/each}
</div>
<div class="grid grid-cols-7 text-center text-[10px]">
{#each miniDays as day}
<button
class="w-6 h-6 flex items-center justify-center rounded-full transition
{day.getMonth() !== miniMonth ? 'text-gray-300 dark:text-gray-600' : ''}
{isToday(day) ? 'bg-blue-500 text-white' : ''}
{day.toDateString() === currentDate.toDateString() && !isToday(day)
? 'bg-gray-200 dark:bg-gray-700'
: ''}
{!isToday(day) && day.toDateString() !== currentDate.toDateString()
? 'hover:bg-gray-100 dark:hover:bg-gray-800'
: ''}"
on:click={() => onDateSelect(day)}
>
{day.getDate()}
</button>
{/each}
</div>
</div>
<!-- Calendar List -->
<div>
<div class="flex items-center justify-between mb-1 px-1">
<div class="text-[11px] text-gray-400 dark:text-gray-500 uppercase tracking-wider">
{$i18n.t('Calendars')}
</div>
</div>
{#each calendars as cal (cal.id)}
<div class="group flex items-center w-full">
<button
class="flex items-center gap-2 px-2 py-1 rounded-lg text-xs transition
hover:bg-gray-50 dark:hover:bg-gray-800/50 flex-1 text-left min-w-0"
on:click={() => onToggle(cal.id)}
>
<span
class="shrink-0 size-2.5 rounded-full transition-opacity"
style="background-color: {cal.color || '#3b82f6'}; opacity: {visibleCalendarIds.has(
cal.id
)
? '1'
: '0.25'};"
></span>
<span
class="truncate flex-1 {visibleCalendarIds.has(cal.id)
? ''
: 'text-gray-400 dark:text-gray-500'}"
>
{cal.name}
</span>
{#if isDeletable(cal)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="shrink-0 p-0.5 rounded opacity-0 group-hover:opacity-100
transition-all duration-150"
role="button"
tabindex="-1"
title={$i18n.t('Delete calendar')}
on:click|stopPropagation={(e) => handleDeleteClick(e, cal)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</span>
{/if}
</button>
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,323 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import type { CalendarEventModel, CalendarModel } from '$lib/apis/calendar';
import CalendarEventChip from './CalendarEventChip.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let events: CalendarEventModel[] = [];
export let calendars: CalendarModel[] = [];
export let visibleCalendarIds: Set<string> = new Set();
export let view: 'month' | 'week' | 'day' = 'month';
export let currentDate: Date = new Date();
const NS = 1_000_000;
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$: calColorMap = calendars.reduce(
(acc, c) => ({ ...acc, [c.id]: c.color }),
{} as Record<string, string | null>
);
$: filteredEvents = events.filter((e) => visibleCalendarIds.has(e.calendar_id));
// Pre-group events by day key so the template reactively updates when events change
$: eventsByDay = (() => {
const map: Record<string, CalendarEventModel[]> = {};
for (const e of filteredEvents) {
const startMs = e.start_at / NS;
const endMs = (e.end_at || e.start_at) / NS;
// Get local midnight for event start/end
const startDate = new Date(startMs);
const endDate = new Date(endMs);
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
while (d.getTime() <= last) {
const key = d.getTime().toString();
(map[key] ??= []).push(e);
d.setDate(d.getDate() + 1);
}
}
return map;
})();
$: monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
$: calendarStart = (() => {
const d = new Date(monthStart);
d.setDate(d.getDate() - d.getDay());
return d;
})();
$: monthDays = (() => {
const days: Date[] = [];
const d = new Date(calendarStart);
for (let i = 0; i < 42; i++) {
days.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return days;
})();
$: weekStart = (() => {
const d = new Date(currentDate);
d.setDate(d.getDate() - d.getDay());
d.setHours(0, 0, 0, 0);
return d;
})();
$: weekDays = (() => {
const days: Date[] = [];
const d = new Date(weekStart);
for (let i = 0; i < 7; i++) {
days.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return days;
})();
$: hours = Array.from({ length: 24 }, (_, i) => i);
function isToday(d: Date): boolean {
return d.toDateString() === new Date().toDateString();
}
function isCurrentMonth(d: Date): boolean {
return d.getMonth() === currentDate.getMonth();
}
function getEventsForDay(day: Date): CalendarEventModel[] {
const dayStartMs = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const dayEndMs = dayStartMs + 86_400_000;
return filteredEvents.filter((e) => {
const startMs = e.start_at / NS;
const endMs = (e.end_at || e.start_at) / NS;
return startMs < dayEndMs && endMs >= dayStartMs;
});
}
function getEventsForHour(
day: Date,
hour: number,
eventsList: CalendarEventModel[] = filteredEvents
): CalendarEventModel[] {
const hourStartMs = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour).getTime();
const hourEndMs = hourStartMs + 3_600_000;
return eventsList.filter((e) => {
const startMs = e.start_at / NS;
return startMs >= hourStartMs && startMs < hourEndMs;
});
}
function formatHour(h: number): string {
if (h === 0) return '12 AM';
if (h < 12) return `${h} AM`;
if (h === 12) return '12 PM';
return `${h - 12} PM`;
}
function handleDayClick(day: Date) {
currentDate = day;
const ms = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9).getTime();
dispatch('createEvent', { start_at: ms * NS });
}
function goToDayView(day: Date) {
currentDate = day;
view = 'day';
dispatch('viewChange', view);
dispatch('navigate', { date: currentDate });
}
function handleHourClick(day: Date, hour: number) {
currentDate = day;
const ms = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour).getTime();
dispatch('createEvent', { start_at: ms * NS });
}
function handleEventClick(event: CalendarEventModel) {
dispatch('eventClick', event);
}
</script>
<div class="flex flex-col h-full w-full min-h-0 min-w-0">
<!-- Month View -->
{#if view === 'month'}
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
<div class="grid grid-cols-7">
{#each DAY_NAMES as day}
<div class="px-2 py-1.5 text-xs text-gray-400 dark:text-gray-500 text-left truncate">
{$i18n.t(day)}
</div>
{/each}
</div>
<div
class="flex-1 grid grid-cols-7 auto-rows-fr min-h-0 rounded-2xl overflow-hidden bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30"
>
{#each monthDays as day, i}
{@const dayKey = new Date(day.getFullYear(), day.getMonth(), day.getDate())
.getTime()
.toString()}
{@const dayEvents = eventsByDay[dayKey] || []}
{@const col = i % 7}
{@const row = Math.floor(i / 7)}
<button
class="p-1 min-h-0 text-left overflow-hidden transition cursor-pointer flex flex-col
{isCurrentMonth(day) ? '' : 'opacity-40'}
hover:bg-gray-50/80 dark:hover:bg-gray-850/30
{col > 0 ? 'border-l border-gray-100/20 dark:border-gray-850/20' : ''}
{row > 0 ? 'border-t border-gray-100/20 dark:border-gray-850/20' : ''}"
on:click={() => handleDayClick(day)}
>
<div class="flex justify-start px-0.5 mb-0.5">
<span
class="text-xs w-6 h-6 flex items-center justify-center rounded-full
{isToday(day) ? 'bg-blue-500 text-white' : 'text-gray-500 dark:text-gray-400'}"
>
{day.getDate()}
</span>
</div>
<div class="flex flex-col gap-0 flex-1 overflow-hidden">
{#each dayEvents.slice(0, 3) as evt (evt.instance_id || evt.id)}
<CalendarEventChip
event={evt}
calendarColor={calColorMap[evt.calendar_id]}
on:click={() => handleEventClick(evt)}
/>
{/each}
{#if dayEvents.length > 3}
<!-- svelte-ignore a11y-click-events-have-key-events --><!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-[10px] text-gray-400 dark:text-gray-500 px-1 mt-auto hover:text-gray-700 dark:hover:text-gray-200 text-left w-full truncate z-10"
on:click|stopPropagation={() => goToDayView(day)}
>
+{dayEvents.length - 3} more
</div>
{/if}
</div>
</button>
{/each}
</div>
</div>
<!-- Week View -->
{:else if view === 'week'}
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
<div
class="flex-1 rounded-2xl bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30 overflow-hidden relative"
>
<div class="absolute inset-0 overflow-x-auto flex flex-col">
<div class="min-w-[700px] flex flex-col flex-1">
<div
class="grid grid-cols-[52px_repeat(7,1fr)] shrink-0 border-b border-gray-100/30 dark:border-gray-850/30"
>
<div></div>
{#each weekDays as day}
<div
class="text-center py-2.5 {day.getDay() > 0
? 'border-l border-gray-100/20 dark:border-gray-850/20'
: ''}"
>
<div class="text-[11px] text-gray-400 dark:text-gray-500">
{DAY_NAMES[day.getDay()]}
</div>
<div
class="text-sm mt-0.5 w-7 h-7 flex items-center justify-center mx-auto rounded-full {isToday(
day
)
? 'bg-blue-500 text-white'
: ''}"
>
{day.getDate()}
</div>
</div>
{/each}
</div>
<div class="flex-1 overflow-y-auto">
{#each hours as hour}
<div
class="grid grid-cols-[52px_repeat(7,1fr)] min-h-[52px] {hour > 0
? 'border-t border-gray-100/15 dark:border-gray-850/15'
: ''}"
>
<div
class="text-[10px] text-gray-400 dark:text-gray-500 text-right pr-2 select-none -mt-1.5 z-10"
>
{hour > 0 ? formatHour(hour) : ''}
</div>
{#each weekDays as day}
{@const hourEvents = getEventsForHour(day, hour, filteredEvents)}
<button
class="px-0.5 py-0.5 {day.getDay() > 0
? 'border-l border-gray-100/15 dark:border-gray-850/15'
: ''} hover:bg-gray-50/50 dark:hover:bg-gray-850/20 transition cursor-pointer min-w-0 flex flex-col"
on:click={() => handleHourClick(day, hour)}
>
<div class="flex flex-col gap-0.5 w-full min-h-0">
{#each hourEvents.slice(0, 3) as evt (evt.instance_id || evt.id)}
<CalendarEventChip
event={evt}
calendarColor={calColorMap[evt.calendar_id]}
on:click={() => handleEventClick(evt)}
/>
{/each}
{#if hourEvents.length > 3}
<!-- svelte-ignore a11y-click-events-have-key-events --><!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-[10px] text-gray-400 dark:text-gray-500 px-1 mt-auto hover:text-gray-700 dark:hover:text-gray-200 text-left w-full truncate z-10"
on:click|stopPropagation={() => goToDayView(day)}
>
+{hourEvents.length - 3} more
</div>
{/if}
</div>
</button>
{/each}
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
<!-- Day View -->
{:else}
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
<div
class="flex-1 rounded-2xl overflow-hidden bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30 overflow-y-auto"
>
{#each hours as hour}
{@const hourEvents = getEventsForHour(currentDate, hour, filteredEvents)}
<div
class="flex min-h-[52px] {hour > 0
? 'border-t border-gray-100/15 dark:border-gray-850/15'
: ''}"
>
<div
class="w-14 shrink-0 text-[10px] text-gray-400 dark:text-gray-500 text-right pr-3 mt-1 select-none"
>
{formatHour(hour)}
</div>
<button
class="flex-1 border-l border-gray-100/15 dark:border-gray-850/15 px-1.5 py-0.5
hover:bg-gray-50/50 dark:hover:bg-gray-850/20 transition cursor-pointer flex flex-col text-left justify-start"
on:click={() => handleHourClick(currentDate, hour)}
>
<div class="flex flex-col gap-0.5 w-full">
{#each hourEvents as evt (evt.instance_id || evt.id)}
<CalendarEventChip
event={evt}
calendarColor={calColorMap[evt.calendar_id]}
on:click={() => handleEventClick(evt)}
/>
{/each}
</div>
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>

View file

@ -13,6 +13,7 @@
import { get, type Unsubscriber, type Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { WEBUI_BASE_URL } from '$lib/constants';
import equal from 'fast-deep-equal';
import {
chatId,
@ -277,7 +278,7 @@
};
let oldSelectedModelIds = [''];
$: if (JSON.stringify(selectedModelIds) !== JSON.stringify(oldSelectedModelIds)) {
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
onSelectedModelIdsChange();
}
@ -512,6 +513,7 @@
}
}
history = history;
return; // Patches history.messages directly; skip the trailing write-back.
} else if (type === 'chat:message:favorite') {
// Update message favorite status
message.favorite = data.favorite;
@ -673,7 +675,7 @@
if (
$selectedFolder &&
selectedModels.filter((modelId) => modelId !== '').length > 0 &&
JSON.stringify($selectedFolder?.data?.model_ids) !== JSON.stringify(selectedModels)
!equal($selectedFolder?.data?.model_ids, selectedModels)
) {
const res = await updateFolderById(localStorage.token, $selectedFolder.id, {
data: {
@ -755,10 +757,7 @@
const selectedFolderSubscribe = selectedFolder.subscribe(async (folder) => {
await tick();
if (
folder?.data?.model_ids &&
JSON.stringify(selectedModels) !== JSON.stringify(folder.data.model_ids)
) {
if (folder?.data?.model_ids && !equal(selectedModels, folder.data.model_ids)) {
selectedModels = folder.data.model_ids;
console.log('Set selectedModels from folder data:', selectedModels);
@ -1834,8 +1833,7 @@
);
chatFiles = chatFiles.filter(
// Remove duplicates
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
(item, index, array) => array.findIndex((i) => equal(i, item)) === index
);
// Create user message
@ -1879,7 +1877,7 @@
$models.map((m) => m.id).includes(modelId) ? modelId : ''
);
if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) {
if (!equal(selectedModels, _selectedModels)) {
selectedModels = _selectedModels;
}
@ -2174,10 +2172,7 @@
)
);
// Remove duplicates
files = files.filter(
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
);
files = files.filter((item, index, array) => array.findIndex((i) => equal(i, item)) === index);
scrollToBottom();
eventTarget.dispatchEvent(
@ -2306,6 +2301,9 @@
// Use the user-selected terminal from the dropdown
const activeTerminalId = $selectedTerminalId ?? null;
// Only send terminal_id if the model has terminal capability enabled
const terminalEnabled = model.info?.meta?.capabilities?.terminal ?? true;
const res = await generateOpenAIChatCompletion(
localStorage.token,
{
@ -2323,7 +2321,7 @@
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
tool_ids: toolIds.length > 0 ? toolIds : undefined,
skill_ids: skillIds.length > 0 ? skillIds : undefined,
terminal_id: activeTerminalId ?? undefined,
terminal_id: terminalEnabled ? (activeTerminalId ?? undefined) : undefined,
tool_servers: [
...($toolServers ?? []).filter(
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
@ -2408,8 +2406,11 @@
taskIds = newTaskIds;
}
// Backend returns chat_id for new chats — set store + URL
if (res.chat_id && $chatId !== res.chat_id) {
// Backend returns chat_id for new chats — set store + URL.
// Only update if the user hasn't navigated to a different chat
// while the request was in flight (prevents overwriting $chatId
// and causing spurious toast notifications / state duplication).
if (res.chat_id && $chatId !== res.chat_id && $chatId === _chatId) {
await chatId.set(res.chat_id);
if (!$temporaryChatEnabled) {
window.history.replaceState(history.state, '', `/c/${res.chat_id}`);
@ -3007,7 +3008,7 @@
if (e.detail || files.length > 0) {
await tick();
submitHandler(e.detail.replaceAll('\n\n', '\n'));
submitHandler(e.detail);
}
}}
/>
@ -3050,7 +3051,7 @@
clearDraft();
if (e.detail || files.length > 0) {
await tick();
submitHandler(e.detail.replaceAll('\n\n', '\n'));
submitHandler(e.detail);
}
}}
/>

View file

@ -72,7 +72,10 @@
$: showControlsTab = $user?.role === 'admin' || ($user?.permissions?.chat?.controls ?? true);
$: showFilesTab =
!!$selectedTerminalId ||
($selectedTerminalId &&
(($terminalServers ?? []).some((t) => t.id && t.id === $selectedTerminalId) ||
$user?.role === 'admin' ||
($user?.permissions?.features?.direct_tool_servers ?? true))) ||
(codeInterpreterEnabled && $config?.code?.interpreter_engine !== 'jupyter');
$: showOverviewTab = hasMessages;
@ -96,13 +99,22 @@
}
// Auto-open Files tab when a terminal is selected (suppress panel open when full-screen)
$: if ($selectedTerminalId) {
$: if ($selectedTerminalId && showFilesTab) {
activeTab = 'files';
if (largeScreen) {
showControls.set(true);
}
}
// Clear selected direct terminal if user lost permission
$: if (
$selectedTerminalId &&
!($terminalServers ?? []).some((t) => t.id && t.id === $selectedTerminalId) &&
!($user?.role === 'admin' || ($user?.permissions?.features?.direct_tool_servers ?? true))
) {
selectedTerminalId.set(null);
}
// Attach a terminal file to the chat input
const handleTerminalAttach = async (blob: Blob, name: string, contentType: string) => {
const tempItemId = uuidv4();

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, tick, onDestroy } from 'svelte';
import { formatFileSize } from '$lib/utils';
import type { FileEntry } from '$lib/apis/terminal';
@ -8,6 +9,7 @@
import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Pencil from '../../icons/Pencil.svelte';
import Clipboard from '../../icons/Clipboard.svelte';
const i18n = getContext('i18n');
@ -315,6 +317,24 @@
<div class="flex items-center">{$i18n.t('Download')}</div>
</button>
<button
type="button"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"
on:click={(e) => {
e.stopPropagation();
const path =
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`;
navigator.clipboard.writeText(path).then(() => {
toast.success($i18n.t('Path copied'));
});
}}
>
<Clipboard className="size-4" strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Copy Path')}</div>
</button>
<button
type="button"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2 text-sm"

View file

@ -495,6 +495,11 @@
$models.find((m) => m.id === model)?.info?.meta?.capabilities?.code_interpreter ?? true
);
let terminalCapableModels = [];
$: terminalCapableModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter(
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.terminal ?? true
);
let toggleFilters = [];
$: toggleFilters = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels)
.map((id) => ($models.find((model) => model.id === id) || {})?.filters ?? [])
@ -530,6 +535,11 @@
codeInterpreterEnabled = false;
}
// Clear selected terminal when model doesn't support terminal
$: if ($selectedTerminalId && terminalCapableModels.length === 0) {
selectedTerminalId.set(null);
}
const scrollToBottom = () => {
const element = document.getElementById('messages-container');
element.scrollTo({
@ -1747,7 +1757,19 @@
<Tooltip content={filter?.name} placement="top">
<button
on:click|preventDefault={() => {
selectedFilterIds = selectedFilterIds.filter((id) => id !== filterId);
if (
filter?.has_user_valves &&
($_user?.role === 'admin' ||
($_user?.permissions?.chat?.valves ?? true))
) {
selectedValvesType = 'function';
selectedValvesItemId = filterId;
showValvesModal = true;
} else {
selectedFilterIds = selectedFilterIds.filter(
(id) => id !== filterId
);
}
}}
type="button"
class="group p-[7px] flex gap-1.5 items-center text-sm rounded-full transition-colors duration-300 focus:outline-hidden max-w-full overflow-hidden {selectedFilterIds.includes(
@ -1770,7 +1792,18 @@
{:else}
<Sparkles className="size-4" strokeWidth="1.75" />
{/if}
<div class="hidden group-hover:block">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="hidden group-hover:block"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedFilterIds = selectedFilterIds.filter(
(id) => id !== filterId
);
}}
>
<XMark className="size-4" strokeWidth="1.75" />
</div>
</button>
@ -1908,7 +1941,10 @@
{#if !history?.currentId || history.messages[history.currentId]?.done == true}
<!-- Terminal Server Selector -->
{#if ($terminalServers ?? []).length > 0 || ($settings?.terminalServers ?? []).some((s) => s.url)}
{@const hasDirectToolServerAccess =
$_user?.role === 'admin' ||
($_user?.permissions?.features?.direct_tool_servers ?? true)}
{#if terminalCapableModels.length > 0 && (($terminalServers ?? []).some((t) => t.id) || (hasDirectToolServerAccess && (($terminalServers ?? []).some((t) => !t.id) || ($settings?.terminalServers ?? []).some((s) => s.url))))}
<TerminalMenu bind:show={showTerminalMenu} />
{/if}

View file

@ -125,7 +125,7 @@
class="p-0.5 rounded-md text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition"
on:click|stopPropagation={() => {
show = false;
showSettings.set(true);
showSettings.set('tools');
}}
>
<svg

View file

@ -60,102 +60,7 @@
export let messagesCount: number | null = 8;
let messagesLoading = false;
// Off-screen message unloading. Heights are measured on scroll so spacers
// always match real sizes — no scroll jumps, no feedback loops needed.
const OVERSCAN = 3;
const DEFAULT_HEIGHT = 150;
let visibleStart = 0;
let visibleEnd = 0;
let messageHeights = new Map();
let topSpacerHeight = 0;
let bottomSpacerHeight = 0;
let pendingCull = null;
// Helper: get height for a message (cached or default)
const heightOf = (id) => messageHeights.get(id) ?? DEFAULT_HEIGHT;
/** Measure all currently rendered message elements and cache their heights */
const measureMessageHeights = () => {
const elements = document
.getElementById('messages-container')
?.querySelectorAll('[role="listitem"]');
if (!elements) return;
messageHeights = new Map([
...messageHeights,
...Array.from(elements)
.map((el, i) => [messages[visibleStart + i]?.id, el.getBoundingClientRect().height])
.filter(([id]) => id != null)
]);
};
/** Compute visible range from current scroll position and apply */
const updateVisibleRange = () => {
const container = document.getElementById('messages-container');
if (!container || messages.length === 0) return;
const st = container.scrollTop;
const ch = container.clientHeight;
// Build prefix sums from measured heights
const prefixSums = messages.reduce(
(acc, m) => [...acc, acc[acc.length - 1] + heightOf(m.id)],
[0]
);
const firstVisible = Math.max(0, prefixSums.findIndex((h) => h > st) - 1);
const lastVisible = prefixSums.findIndex((h) => h > st + ch);
// Only cull messages that have been measured (so spacer height is accurate)
// findIndex returns -1 when all are measured → no limit on culling
const firstUnmeasured = messages.findIndex((m) => !messageHeights.has(m.id));
const cullLimit = firstUnmeasured === -1 ? messages.length : firstUnmeasured;
visibleStart = Math.max(0, Math.min(firstVisible - OVERSCAN, cullLimit));
visibleEnd = Math.min(
messages.length,
(lastVisible === -1 ? messages.length : lastVisible) + OVERSCAN
);
topSpacerHeight = prefixSums[visibleStart] ?? 0;
bottomSpacerHeight = (prefixSums[messages.length] ?? 0) - (prefixSums[visibleEnd] ?? 0);
};
/** Scroll handler: measure every frame, cull via rAF (same throttle as pendingRebuild) */
const handleContainerScroll = () => {
measureMessageHeights();
// Don't cull during progressive loading
if (messagesLoading) return;
if (!pendingCull) {
pendingCull = requestAnimationFrame(() => {
pendingCull = null;
updateVisibleRange();
});
}
};
let scrollListenerAttached = false;
const attachScrollListener = () => {
if (scrollListenerAttached) return;
const container = document.getElementById('messages-container');
if (!container) return;
container.addEventListener('scroll', handleContainerScroll, { passive: true });
scrollListenerAttached = true;
};
onMount(() => {
attachScrollListener();
});
onDestroy(() => {
const container = document.getElementById('messages-container');
if (container && scrollListenerAttached) {
container.removeEventListener('scroll', handleContainerScroll);
}
cancelAnimationFrame(pendingCull);
cancelAnimationFrame(pendingRebuild);
});
@ -169,12 +74,6 @@
buildMessages();
// Show all messages during progressive loading (no culling)
visibleStart = 0;
visibleEnd = messages.length;
topSpacerHeight = 0;
bottomSpacerHeight = 0;
await tick();
messagesLoading = false;
@ -201,7 +100,6 @@
}
messages = _messages.reverse();
visibleEnd = messages.length;
};
// Throttle message list rebuilds to once per animation frame during streaming.
@ -220,8 +118,6 @@
cancelAnimationFrame(pendingRebuild);
pendingRebuild = null;
buildMessages();
// No explicit culling needed — scrollToBottom will fire a scroll event,
// which triggers handleContainerScroll → rAF → updateVisibleRange
} else if (_messages) {
// Content update (streaming) — throttle to once per frame
if (!pendingRebuild) {
@ -570,13 +466,7 @@
</Loader>
{/if}
<ul role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false">
<!-- Top spacer: sum of cached heights for messages above visible range -->
{#if topSpacerHeight > 0}
<div style="height: {topSpacerHeight}px" aria-hidden="true" />
{/if}
{#each messages.slice(visibleStart, visibleEnd) as message, i (message.id)}
{@const messageIdx = visibleStart + i}
{#each messages as message, messageIdx (message.id)}
<Message
{chatId}
bind:history
@ -605,11 +495,6 @@
{topPadding}
/>
{/each}
<!-- Bottom spacer: sum of cached heights for messages below visible range -->
{#if bottomSpacerHeight > 0}
<div style="height: {bottomSpacerHeight}px" aria-hidden="true" />
{/if}
</ul>
</section>
<div class="pb-18" />

View file

@ -228,7 +228,9 @@
rawContent.length > CONTENT_PREVIEW_LIMIT &&
!expandedDocs.has(documentIdx)}
{#if $settings?.renderMarkdownInPreviews ?? true}
<div class="text-sm prose dark:prose-invert max-w-full">
<div
class="text-sm prose dark:prose-invert markdown-prose-sm min-w-full max-w-full"
>
<Markdown
content={isTruncated
? rawContent.slice(0, CONTENT_PREVIEW_LIMIT)

View file

@ -15,6 +15,7 @@
} from '$lib/utils';
import 'highlight.js/styles/github-dark.min.css';
import equal from 'fast-deep-equal';
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte';
@ -391,7 +392,7 @@
$: if (token) {
if (token.text !== _token?.text || token.raw !== _token?.raw) {
_token = token;
} else if (JSON.stringify(token) !== JSON.stringify(_token)) {
} else if (!equal(token, _token)) {
_token = token;
}
}

View file

@ -71,17 +71,11 @@
};
const updateHandler = (content) => {
if (content) {
if (done) {
cancelAnimationFrame(pendingUpdate);
if (content && !pendingUpdate) {
pendingUpdate = requestAnimationFrame(() => {
pendingUpdate = null;
parseTokens();
} else if (!pendingUpdate) {
pendingUpdate = requestAnimationFrame(() => {
pendingUpdate = null;
parseTokens();
});
}
});
}
};

View file

@ -49,7 +49,7 @@
role="listitem"
class="flex flex-col justify-between px-5 mb-3 w-full {($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-5xl'} mx-auto rounded-lg group"
: 'max-w-5xl'} mx-auto rounded-lg group message-listitem"
>
{#if history.messages[messageId]}
{#if history.messages[messageId].role === 'user'}
@ -128,3 +128,14 @@
{/if}
{/if}
</div>
<style>
/* Browser-native virtualization: skip rendering of off-screen messages
without destroying their component trees. Replaces the JS-based
culling that caused catastrophic mount/destroy thrashing. */
.message-listitem {
content-visibility: auto;
contain-intrinsic-size: auto 150px;
}
</style>

View file

@ -19,6 +19,7 @@
import localizedFormat from 'dayjs/plugin/localizedFormat';
import ProfileImage from './ProfileImage.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import equal from 'fast-deep-equal';
const i18n = getContext('i18n');
dayjs.extend(localizedFormat);
@ -66,7 +67,7 @@
if (source) {
if (message.content !== source.content || message.done !== source.done) {
message = structuredClone(source);
} else if (JSON.stringify(message) !== JSON.stringify(source)) {
} else if (!equal(message, source)) {
message = structuredClone(source);
}
}

View file

@ -37,6 +37,7 @@
removeAllDetails
} from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import equal from 'fast-deep-equal';
import Name from './Name.svelte';
import ProfileImage from './ProfileImage.svelte';
@ -127,7 +128,7 @@
// Avoids 2x O(n) JSON.stringify calls that are always true during streaming anyway
if (message.content !== source.content || message.done !== source.done) {
message = structuredClone(source);
} else if (JSON.stringify(message) !== JSON.stringify(source)) {
} else if (!equal(message, source)) {
// Slow path: full comparison for infrequent changes (sources, annotations, status, etc.)
message = structuredClone(source);
}
@ -184,6 +185,7 @@
let speakingIdx: number | undefined;
let loadingSpeech = false;
let speakAbort: AbortController | null = null;
let showRateComment = false;
@ -201,39 +203,39 @@
};
const stopAudio = () => {
speakAbort?.abort();
speakAbort = null;
try {
speechSynthesis.cancel();
$audioQueue?.stop();
} catch {}
if (speaking) {
speaking = false;
speakingIdx = undefined;
}
speaking = false;
speakingIdx = undefined;
loadingSpeech = false;
};
// Resolve voice: model-specific > user settings > config default
const getVoiceId = () =>
model?.info?.meta?.tts?.voice ??
($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice
? ($settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice)
: $config?.audio?.tts?.voice);
const speak = async () => {
if (!(message?.content ?? '').trim().length) {
toast.info($i18n.t('No content to speak'));
return;
}
stopAudio();
speakAbort = new AbortController();
const { signal } = speakAbort;
speaking = true;
const content = removeAllDetails(message.content);
// Get voice: model-specific > user settings > config default
const getVoiceId = () => {
// Check for model-specific TTS voice first
if (model?.info?.meta?.tts?.voice) {
return model.info.meta.tts.voice;
}
// Fall back to user settings or config default
if ($settings?.audio?.tts?.defaultVoice === $config.audio.tts.voice) {
return $settings?.audio?.tts?.voice ?? $config?.audio?.tts?.voice;
}
return $config?.audio?.tts?.voice;
};
if ($config.audio.tts.engine === '') {
let voices = [];
const getVoicesLoop = setInterval(() => {
@ -241,16 +243,10 @@
if (voices.length > 0) {
clearInterval(getVoicesLoop);
const voiceId = getVoiceId();
const voice = voices?.filter((v) => v.voiceURI === voiceId)?.at(0) ?? undefined;
console.log(voice);
const voice = voices.find((v) => v.voiceURI === getVoiceId());
const speech = new SpeechSynthesisUtterance(content);
speech.rate = $settings.audio?.tts?.playbackRate ?? 1;
console.log(speech);
speech.onend = () => {
speaking = false;
if ($settings.conversationMode) {
@ -280,9 +276,7 @@
);
if (!messageContentParts.length) {
console.log('No content to speak');
toast.info($i18n.t('No content to speak'));
speaking = false;
loadingSpeech = false;
return;
@ -302,41 +296,43 @@
await $TTSWorker.init();
}
for (const [idx, sentence] of messageContentParts.entries()) {
for (const [, sentence] of messageContentParts.entries()) {
if (signal.aborted) return;
const url = await $TTSWorker
.generate({
text: sentence,
voice: voiceId
})
.generate({ text: sentence, voice: voiceId })
.catch((error) => {
console.error(error);
toast.error(`${error}`);
speaking = false;
loadingSpeech = false;
});
if (signal.aborted) return;
if (url && speaking) {
$audioQueue.enqueue(url);
loadingSpeech = false;
}
}
} else {
for (const [idx, sentence] of messageContentParts.entries()) {
for (const [, sentence] of messageContentParts.entries()) {
if (signal.aborted) return;
const res = await synthesizeOpenAISpeech(localStorage.token, voiceId, sentence).catch(
(error) => {
console.error(error);
toast.error(`${error}`);
speaking = false;
loadingSpeech = false;
}
);
if (signal.aborted) return;
if (res && speaking) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
$audioQueue.enqueue(url);
loadingSpeech = false;
}
@ -1385,8 +1381,12 @@
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
showDeleteConfirm = true;
on:click={(e) => {
if (e.shiftKey) {
deleteMessageHandler();
} else {
showDeleteConfirm = true;
}
}}
>
<svg

View file

@ -3,6 +3,7 @@
const i18n = getContext('i18n');
import StatusItem from './StatusHistory/StatusItem.svelte';
import equal from 'fast-deep-equal';
export let statusHistory = [];
export let expand = false;
@ -21,10 +22,7 @@
status = history.at(-1);
}
$: if (
statusHistory.length !== history.length ||
JSON.stringify(statusHistory) !== JSON.stringify(history)
) {
$: if (!equal(statusHistory, history)) {
history = statusHistory;
}
</script>

View file

@ -7,6 +7,7 @@
import { user as _user } from '$lib/stores';
import { copyToClipboard as _copyToClipboard, formatDate } from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import equal from 'fast-deep-equal';
import Name from './Name.svelte';
import ProfileImage from './ProfileImage.svelte';
@ -58,7 +59,7 @@
if (source) {
if (message.content !== source.content) {
message = structuredClone(source);
} else if (JSON.stringify(message) !== JSON.stringify(source)) {
} else if (!equal(message, source)) {
message = structuredClone(source);
}
}
@ -550,8 +551,12 @@
class="{($settings?.highContrastMode ?? false)
? ''
: 'invisible group-hover:visible'} p-1 rounded-sm dark:hover:text-white hover:text-black transition"
on:click={() => {
showDeleteConfirm = true;
on:click={(e) => {
if (e.shiftKey) {
deleteMessageHandler();
} else {
showDeleteConfirm = true;
}
}}
>
<svg

View file

@ -6,6 +6,7 @@
import Tooltip from '../common/Tooltip.svelte';
import { updateUserSettings } from '$lib/apis/users';
import equal from 'fast-deep-equal';
const i18n = getContext('i18n');
export let selectedModels = [''];
@ -43,7 +44,7 @@
$models.map((m) => m.id).includes(model) ? model : ''
);
if (JSON.stringify(_selectedModels) !== JSON.stringify(selectedModels)) {
if (!equal(_selectedModels, selectedModels)) {
selectedModels = _selectedModels;
}
}

View file

@ -106,7 +106,7 @@
}}
/>
{:else}
<div class="flex flex-row justify-center gap-3 @sm:gap-3.5 w-fit px-5 max-w-xl">
<div class="flex flex-row justify-center gap-2.5 @sm:gap-3 w-fit px-5 max-w-xl">
<div class="flex shrink-0 justify-center">
<div class="flex -space-x-4 mb-0.5" in:fade={{ duration: 100 }}>
{#each models as model, modelIdx}

View file

@ -31,11 +31,16 @@
const i18n = getContext('i18n');
export let show = false;
export let show: boolean | string = false;
$: if (show) {
if (typeof show === 'string') {
selectedTab = show;
show = true;
}
addScrollListener();
} else {
selectedTab = 'general';
removeScrollListener();
}

View file

@ -3,24 +3,32 @@
import { models, config } from '$lib/stores';
import { toast } from 'svelte-sonner';
import { deleteSharedChatById, getChatById, shareChatById } from '$lib/apis/chats';
import {
deleteSharedChatById,
getChatById,
shareChatById,
getChatAccessGrants,
updateChatAccessGrants
} from '$lib/apis/chats';
import { copyToClipboard } from '$lib/utils';
import Modal from '../common/Modal.svelte';
import Link from '../icons/Link.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
export let chatId;
let chat = null;
let shareUrl = null;
let accessGrants: any[] = [];
const i18n = getContext('i18n');
const shareLocalChat = async () => {
const _chat = chat;
const sharedChat = await shareChatById(localStorage.token, chatId);
shareUrl = `${window.location.origin}/s/${sharedChat.id}`;
shareUrl = `${window.location.origin}/s/${sharedChat.share_id}`;
console.log(shareUrl);
chat = await getChatById(localStorage.token, chatId);
@ -54,6 +62,25 @@
);
};
const loadAccessGrants = async () => {
if (!chatId) return;
try {
accessGrants = (await getChatAccessGrants(localStorage.token, chatId)) ?? [];
} catch (e) {
console.error('Failed to load access grants', e);
accessGrants = [];
}
};
const saveAccessGrants = async () => {
try {
await updateChatAccessGrants(localStorage.token, chatId, accessGrants);
toast.success($i18n.t('Access updated'));
} catch (e) {
toast.error(`${e}`);
}
};
export let show = false;
const isDifferentChat = (_chat) => {
@ -73,8 +100,10 @@
if (isDifferentChat(_chat)) {
chat = _chat;
}
await loadAccessGrants();
} else {
chat = null;
accessGrants = [];
console.log(chat);
}
})();
@ -97,8 +126,8 @@
</div>
{#if chat}
<div class="px-5 pt-4 pb-5 w-full flex flex-col justify-center">
<div class=" text-sm dark:text-gray-300 mb-1">
<div class="px-5 pt-4 pb-5 w-full flex flex-col">
<div class="text-sm dark:text-gray-300">
{#if chat.share_id}
<a href="/s/{chat.share_id}" target="_blank"
>{$i18n.t('You have shared this chat')}
@ -124,70 +153,69 @@
{/if}
</div>
<div class="flex justify-end">
<div class="flex flex-col items-end space-x-1 mt-3">
<div class="flex gap-1">
{#if $config?.features.enable_community_sharing}
<button
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
type="button"
on:click={() => {
shareChat();
show = false;
}}
>
{$i18n.t('Share to Open WebUI Community')}
</button>
{/if}
<button
class="self-center flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
id="copy-and-share-chat-button"
on:click={async () => {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Oh, Safari, you're so special, let's give you some extra love and attention
console.log('isSafari');
const getUrlPromise = async () => {
const url = await shareLocalChat();
return new Blob([url], { type: 'text/plain' });
};
navigator.clipboard
.write([
new ClipboardItem({
'text/plain': getUrlPromise()
})
])
.then(() => {
console.log('Async: Copying to clipboard was successful!');
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
} else {
copyToClipboard(await shareLocalChat());
}
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
show = false;
}}
>
<Link />
{#if chat.share_id}
{$i18n.t('Update and Copy Link')}
{:else}
{$i18n.t('Copy Link')}
{/if}
</button>
</div>
{#if chat.share_id}
<div class="mt-3">
<AccessControl bind:accessGrants accessRoles={['read']} onChange={saveAccessGrants} />
</div>
{/if}
<div class="flex justify-end gap-1 mt-3">
{#if $config?.features.enable_community_sharing}
<button
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full"
type="button"
on:click={() => {
shareChat();
}}
>
{$i18n.t('Share to Open WebUI Community')}
</button>
{/if}
<button
class="flex items-center gap-1 px-3.5 py-2 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
id="copy-and-share-chat-button"
on:click={async () => {
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (isSafari) {
console.log('isSafari');
const getUrlPromise = async () => {
const url = await shareLocalChat();
return new Blob([url], { type: 'text/plain' });
};
navigator.clipboard
.write([
new ClipboardItem({
'text/plain': getUrlPromise()
})
])
.then(() => {
console.log('Async: Copying to clipboard was successful!');
return true;
})
.catch((error) => {
console.error('Async: Could not copy text: ', error);
return false;
});
} else {
copyToClipboard(await shareLocalChat());
}
toast.success($i18n.t('Copied shared chat URL to clipboard!'));
}}
>
<Link />
{#if chat.share_id}
{$i18n.t('Update and Copy Link')}
{:else}
{$i18n.t('Copy Link')}
{/if}
</button>
</div>
</div>
{/if}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import equal from 'fast-deep-equal';
marked.use({
breaks: true,
@ -36,6 +37,18 @@
});
turndownService.escape = (string) => string;
// Produce single newlines between paragraphs instead of double.
// TipTap wraps every line in <p> tags; the default Turndown rule emits
// \n\n around each paragraph which then required a destructive
// replaceAll('\n\n','\n') that also wiped blank lines inside code blocks.
// This rule eliminates that hack so <pre><code> content is untouched.
turndownService.addRule('singleNewlineParagraphs', {
filter: 'p',
replacement: function (content) {
return '\n' + content + '\n';
}
});
// Use turndown-plugin-gfm for proper GFM table support
turndownService.use(gfm);
@ -435,7 +448,6 @@
export const setText = (text: string) => {
if (!editor || !editor.view) return;
text = text.replaceAll('\n\n', '\n');
if (text === '') {
editor.commands.clearContent();
@ -1246,7 +1258,7 @@
}
if (json) {
if (JSON.stringify(value) !== JSON.stringify(jsonValue)) {
if (!equal(value, jsonValue)) {
editor.commands.setContent(value);
selectTemplate();
}

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
import Sortable from 'sortablejs';
import { goto } from '$app/navigation';
import {
@ -41,11 +42,14 @@
toggleChatPinnedStatusById,
getChatById,
updateChatFolderIdById,
importChats
importChats,
deleteAllChats,
getChatListBySearchText
} from '$lib/apis/chats';
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
import { createNewNote, getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes';
import { updateUserSettings } from '$lib/apis/users';
import { checkActiveChats } from '$lib/apis/tasks';
import { getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes';
import { createNoteHandler } from '$lib/components/notes/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
@ -67,10 +71,12 @@
import Sidebar from '../icons/Sidebar.svelte';
import PinnedModelList from './Sidebar/PinnedModelList.svelte';
import Note from '../icons/Note.svelte';
import Code from '../icons/Code.svelte';
import { slide } from 'svelte/transition';
import HotkeyHint from '../common/HotkeyHint.svelte';
const BREAKPOINT = 768;
const DEFAULT_PINNED_ITEMS = ['notes', 'workspace'];
let scrollTop = 0;
@ -98,6 +104,70 @@
let newFolderId = null;
$: pinnedItems = $settings?.pinnedMenuItems ?? DEFAULT_PINNED_ITEMS;
const isMenuItemVisible = (id) => {
switch (id) {
case 'notes':
return (
($config?.features?.enable_notes ?? false) &&
($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))
);
case 'workspace':
return (
$user?.role === 'admin' ||
$user?.permissions?.workspace?.models ||
$user?.permissions?.workspace?.knowledge ||
$user?.permissions?.workspace?.prompts ||
$user?.permissions?.workspace?.tools
);
case 'automations':
return (
$config?.features?.enable_automations &&
($user?.role === 'admin' || $user?.permissions?.features?.automations)
);
case 'calendar':
return (
$config?.features?.enable_calendar &&
($user?.role === 'admin' || $user?.permissions?.features?.calendar)
);
case 'playground':
return $user?.role === 'admin';
default:
return false;
}
};
const getMenuItemMeta = (id) => {
const items = {
notes: { label: 'Notes', href: '/notes', iconType: 'note' },
workspace: { label: 'Workspace', href: '/workspace', iconType: 'workspace' },
automations: { label: 'Automations', href: '/automations', iconType: 'automations' },
calendar: { label: 'Calendar', href: '/calendar', iconType: 'calendar' },
playground: { label: 'Playground', href: '/playground', iconType: 'playground' }
};
return items[id];
};
const initPinnedMenuSortable = () => {
const el = document.getElementById('pinned-menu-items-list');
if (el && !$mobile) {
new Sortable(el, {
animation: 150,
onUpdate: async (event) => {
const itemId = event.item.dataset.id;
const newIndex = event.newIndex;
const current = [...pinnedItems];
const oldIndex = current.indexOf(itemId);
current.splice(oldIndex, 1);
current.splice(newIndex, 0, itemId);
settings.set({ ...$settings, pinnedMenuItems: current });
await updateUserSettings(localStorage.token, { ui: $settings });
}
});
}
};
$: if ($selectedFolder) {
initFolders();
}
@ -433,7 +503,7 @@
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
};
onMount(() => {
onMount(async () => {
try {
const width = Number(localStorage.getItem('sidebarWidth'));
if (!Number.isNaN(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
@ -528,6 +598,9 @@
const socketInstance = $socket;
socketInstance?.on('events', chatActiveEventHandler);
await tick();
initPinnedMenuSortable();
return () => {
unsubscribers.forEach((unsubscriber) => unsubscriber());
@ -783,66 +856,80 @@
</Tooltip>
</div>
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div class="">
<Tooltip content={$i18n.t('Notes')} placement="right">
<a
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href="/notes"
on:click={async (e) => {
e.stopImmediatePropagation();
e.preventDefault();
goto('/notes');
itemClickHandler();
}}
draggable="false"
aria-label={$i18n.t('Notes')}
>
<div class=" self-center flex items-center justify-center size-9">
<Note className="size-4.5" />
</div>
</a>
</Tooltip>
</div>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="">
<Tooltip content={$i18n.t('Workspace')} placement="right">
<a
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href="/workspace"
on:click={async (e) => {
e.stopImmediatePropagation();
e.preventDefault();
goto('/workspace');
itemClickHandler();
}}
aria-label={$i18n.t('Workspace')}
draggable="false"
>
<div class=" self-center flex items-center justify-center size-9">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div>
</a>
</Tooltip>
</div>
{/if}
{#each pinnedItems as itemId (itemId)}
{@const meta = getMenuItemMeta(itemId)}
{#if meta && isMenuItemVisible(itemId)}
<div class="">
<Tooltip content={$i18n.t(meta.label)} placement="right">
<a
class=" cursor-pointer flex rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 transition group"
href={meta.href}
on:click={async (e) => {
e.stopImmediatePropagation();
e.preventDefault();
goto(meta.href);
itemClickHandler();
}}
draggable="false"
aria-label={$i18n.t(meta.label)}
>
<div class=" self-center flex items-center justify-center size-9">
{#if itemId === 'notes'}
<Note className="size-4.5" />
{:else if itemId === 'workspace'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
{:else if itemId === 'automations'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
{:else if itemId === 'calendar'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
/>
</svg>
{:else if itemId === 'playground'}
<Code className="size-4.5" />
{/if}
</div>
</a>
</Tooltip>
</div>
{/if}
{/each}
</div>
</button>
@ -930,7 +1017,7 @@
/>
</a>
<a href="/" class="flex flex-1 px-1.5" on:click={newChatHandler}>
<a href="/" class="flex flex-1 px-0.5" on:click={newChatHandler}>
<div
id="sidebar-webui-name"
class=" self-center font-medium text-gray-850 dark:text-white font-primary"
@ -1017,60 +1104,83 @@
</button>
</div>
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div class="px-[0.4375rem] flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-notes-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/notes"
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t('Notes')}
>
<div class="self-center">
<Note className="size-4.5" strokeWidth="2" />
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Notes')}</div>
</div>
</a>
</div>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="px-[0.4375rem] flex justify-center text-gray-800 dark:text-gray-200">
<a
id="sidebar-workspace-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href="/workspace"
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t('Workspace')}
>
<div class="self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4.5"
<div id="pinned-menu-items-list">
{#each pinnedItems as itemId (itemId)}
{@const meta = getMenuItemMeta(itemId)}
{#if meta && isMenuItemVisible(itemId)}
<div
class="px-[0.4375rem] flex justify-center text-gray-800 dark:text-gray-200"
data-id={itemId}
>
<a
id="sidebar-{itemId}-button"
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition"
href={meta.href}
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t(meta.label)}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div>
<div class="self-center">
{#if itemId === 'notes'}
<Note className="size-4.5" strokeWidth="2" />
{:else if itemId === 'workspace'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
{:else if itemId === 'automations'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
{:else if itemId === 'calendar'}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-4.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
/>
</svg>
{:else if itemId === 'playground'}
<Code className="size-4.5" strokeWidth="2" />
{/if}
</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t('Workspace')}</div>
<div class="flex self-center translate-y-[0.5px]">
<div class=" self-center text-sm font-primary">{$i18n.t(meta.label)}</div>
</div>
</a>
</div>
</a>
</div>
{/if}
{/if}
{/each}
</div>
</div>
{#if ($models ?? []).length > 0 && (($settings?.pinnedModels ?? []).length > 0 || $config?.default_pinned_models)}

View file

@ -7,7 +7,15 @@
import { getUsage } from '$lib/apis';
import { getSessionUser, userSignOut } from '$lib/apis/auths';
import { showSettings, mobile, showSidebar, showShortcuts, user, config } from '$lib/stores';
import {
showSettings,
mobile,
showSidebar,
showShortcuts,
user,
config,
settings
} from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
@ -26,7 +34,10 @@
import UserStatusModal from './UserStatusModal.svelte';
import Emoji from '$lib/components/common/Emoji.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { updateUserStatus } from '$lib/apis/users';
import Note from '$lib/components/icons/Note.svelte';
import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import { updateUserStatus, updateUserSettings } from '$lib/apis/users';
import { toast } from 'svelte-sonner';
const i18n = getContext('i18n');
@ -43,9 +54,29 @@
export let showActiveUsers = true;
let showUserStatusModal = false;
let shiftKey = false;
const dispatch = createEventDispatcher();
const DEFAULT_PINNED_ITEMS = ['notes', 'workspace'];
$: pinnedItems = $settings?.pinnedMenuItems ?? DEFAULT_PINNED_ITEMS;
const isPinned = (id: string) => {
return pinnedItems.includes(id);
};
const togglePin = async (id: string) => {
let updated;
if (isPinned(id)) {
updated = pinnedItems.filter((item) => item !== id);
} else {
updated = [...pinnedItems, id];
}
await settings.set({ ...$settings, pinnedMenuItems: updated });
await updateUserSettings(localStorage.token, { ui: $settings });
};
let usage = null;
const getUsageInfo = async () => {
const res = await getUsage(localStorage.token).catch((error) => {
@ -69,6 +100,15 @@
};
</script>
<svelte:window
on:keydown={(e) => {
if (e.key === 'Shift') shiftKey = true;
}}
on:keyup={(e) => {
if (e.key === 'Shift') shiftKey = false;
}}
/>
<ShortcutsModal bind:show={$showShortcuts} />
<UserStatusModal
bind:show={showUserStatusModal}
@ -214,45 +254,9 @@
<div class=" self-center truncate">{$i18n.t('Settings')}</div>
</button>
{#if $user?.role === 'admin' || $user?.permissions?.features?.automations}
<a
href="/automations"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/automations');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Automations')}</div>
</a>
{/if}
{#if role === 'admin'}
<a
href="/playground"
href="/admin"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
@ -261,7 +265,7 @@
}
e.preventDefault();
show = false;
goto('/playground');
goto('/admin');
if ($mobile) {
await tick();
showSidebar.set(false);
@ -269,9 +273,9 @@
}}
>
<div class=" self-center mr-3">
<Code className="size-5" strokeWidth="1.5" />
<UserGroup className="w-5 h-5" strokeWidth="1.5" />
</div>
<div class=" self-center truncate">{$i18n.t('Playground')}</div>
<div class=" self-center truncate">{$i18n.t('Admin Panel')}</div>
</a>
{/if}
@ -296,29 +300,261 @@
<div class=" self-center truncate">{$i18n.t('Archived Chats')}</div>
</button>
<hr class=" border-gray-50/30 dark:border-gray-800/30 my-1 p-0" />
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.models || $user?.permissions?.workspace?.knowledge || $user?.permissions?.workspace?.prompts || $user?.permissions?.workspace?.tools}
<div class="flex items-center w-full">
<a
href="/workspace"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/workspace');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 16.875h3.375m0 0h3.375m-3.375 0V13.5m0 3.375v3.375M6 10.5h2.25a2.25 2.25 0 0 0 2.25-2.25V6a2.25 2.25 0 0 0-2.25-2.25H6A2.25 2.25 0 0 0 3.75 6v2.25A2.25 2.25 0 0 0 6 10.5Zm0 9.75h2.25A2.25 2.25 0 0 0 10.5 18v-2.25a2.25 2.25 0 0 0-2.25-2.25H6a2.25 2.25 0 0 0-2.25 2.25V18A2.25 2.25 0 0 0 6 20.25Zm9.75-9.75H18a2.25 2.25 0 0 0 2.25-2.25V6A2.25 2.25 0 0 0 18 3.75h-2.25A2.25 2.25 0 0 0 13.5 6v2.25a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Workspace')}</div>
</a>
{#if shiftKey}
<Tooltip
content={isPinned('workspace')
? $i18n.t('Unpin from Sidebar')
: $i18n.t('Pin to Sidebar')}
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
on:click|preventDefault|stopPropagation={() => togglePin('workspace')}
>
{#if isPinned('workspace')}
<PinSlash className="size-3.5" strokeWidth="1.5" />
{:else}
<Pin className="size-3.5" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
{/if}
</div>
{/if}
{#if ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<div class="flex items-center w-full">
<a
href="/notes"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/notes');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<Note className="size-5" strokeWidth="1.5" />
</div>
<div class="self-center truncate">{$i18n.t('Notes')}</div>
</a>
{#if shiftKey}
<Tooltip
content={isPinned('notes')
? $i18n.t('Unpin from Sidebar')
: $i18n.t('Pin to Sidebar')}
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
on:click|preventDefault|stopPropagation={() => togglePin('notes')}
>
{#if isPinned('notes')}
<PinSlash className="size-3.5" strokeWidth="1.5" />
{:else}
<Pin className="size-3.5" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
{/if}
</div>
{/if}
{#if $config?.features?.enable_calendar && ($user?.role === 'admin' || $user?.permissions?.features?.calendar)}
<div class="flex items-center w-full">
<a
href="/calendar"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/calendar');
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Calendar')}</div>
</a>
{#if shiftKey}
<Tooltip
content={isPinned('calendar')
? $i18n.t('Unpin from Sidebar')
: $i18n.t('Pin to Sidebar')}
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
on:click|preventDefault|stopPropagation={() => togglePin('calendar')}
>
{#if isPinned('calendar')}
<PinSlash className="size-3.5" strokeWidth="1.5" />
{:else}
<Pin className="size-3.5" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
{/if}
</div>
{/if}
{#if $config?.features?.enable_automations && ($user?.role === 'admin' || $user?.permissions?.features?.automations)}
<div class="flex items-center w-full">
<a
href="/automations"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/automations');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Automations')}</div>
</a>
{#if shiftKey}
<Tooltip
content={isPinned('automations')
? $i18n.t('Unpin from Sidebar')
: $i18n.t('Pin to Sidebar')}
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
on:click|preventDefault|stopPropagation={() => togglePin('automations')}
>
{#if isPinned('automations')}
<PinSlash className="size-3.5" strokeWidth="1.5" />
{:else}
<Pin className="size-3.5" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
{/if}
</div>
{/if}
{#if role === 'admin'}
<a
href="/admin"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
}
e.preventDefault();
show = false;
goto('/admin');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class=" self-center mr-3">
<UserGroup className="w-5 h-5" strokeWidth="1.5" />
</div>
<div class=" self-center truncate">{$i18n.t('Admin Panel')}</div>
</a>
<div class="flex items-center w-full">
<a
href="/playground"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/playground');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<Code className="size-5" strokeWidth="1.5" />
</div>
<div class="self-center truncate">{$i18n.t('Playground')}</div>
</a>
{#if shiftKey}
<Tooltip
content={isPinned('playground')
? $i18n.t('Unpin from Sidebar')
: $i18n.t('Pin to Sidebar')}
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
on:click|preventDefault|stopPropagation={() => togglePin('playground')}
>
{#if isPinned('playground')}
<PinSlash className="size-3.5" strokeWidth="1.5" />
{:else}
<Pin className="size-3.5" strokeWidth="1.5" />
{/if}
</button>
</Tooltip>
{/if}
</div>
{/if}
{#if help}

View file

@ -8,6 +8,7 @@
import { marked } from 'marked';
import { toast } from 'svelte-sonner';
import equal from 'fast-deep-equal';
import { goto } from '$app/navigation';
@ -226,7 +227,7 @@
}
function areContentsEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
return equal(a, b);
}
function insertNoteVersion(note) {

View file

@ -50,21 +50,16 @@
automations: {
label: $i18n.t('Automations'),
description: $i18n.t('Create and manage scheduled automations')
},
calendar: {
label: $i18n.t('Calendar'),
description: $i18n.t('List calendars, search, create, update, and delete calendar events')
}
};
const allTools = Object.keys(toolLabels);
export let builtinTools: Record<string, boolean> = {};
// Initialize missing keys to true (default enabled)
$: {
for (const tool of allTools) {
if (!(tool in builtinTools)) {
builtinTools[tool] = true;
}
}
}
</script>
<div>
@ -77,10 +72,12 @@
<Checkbox
state={builtinTools[tool] !== false ? 'checked' : 'unchecked'}
on:change={(e) => {
builtinTools = {
...builtinTools,
[tool]: e.detail === 'checked'
};
if (e.detail === 'checked') {
delete builtinTools[tool];
} else {
builtinTools[tool] = false;
}
builtinTools = builtinTools;
}}
/>

View file

@ -31,6 +31,12 @@
label: $i18n.t('Code Interpreter'),
description: $i18n.t('Model can execute code and perform calculations')
},
terminal: {
label: $i18n.t('Terminal'),
description: $i18n.t(
'Model can access Open Terminal for command execution and file management'
)
},
usage: {
label: $i18n.t('Usage'),
description: $i18n.t(
@ -60,6 +66,7 @@
web_search?: boolean;
image_generation?: boolean;
code_interpreter?: boolean;
terminal?: boolean;
usage?: boolean;
citations?: boolean;
status_updates?: boolean;

View file

@ -839,9 +839,11 @@
</div>
{/if}
<div class="my-4">
<TerminalSelector bind:terminalId />
</div>
{#if capabilities.terminal}
<div class="my-4">
<TerminalSelector bind:terminalId />
</div>
{/if}
<div class="my-4">
<div class="flex w-full justify-between mb-1">

View file

@ -104,6 +104,7 @@ export const DEFAULT_CAPABILITIES = {
web_search: true,
image_generation: true,
code_interpreter: true,
terminal: true,
citations: true,
status_updates: true,
usage: undefined,

View file

@ -37,8 +37,13 @@
"{{user}}'s Chats": "دردشات {{user}}",
"{{webUIName}} Backend Required": "{{webUIName}} مطلوب",
"*Prompt node ID(s) are required for image generation": "",
"1 hour before": "",
"1 Source": "",
"10 minutes before": "",
"15 minutes before": "",
"1m_time_ago": "",
"30 minutes before": "",
"5 minutes before": "",
"A collaboration channel where people join as members": "",
"A discussion channel where access is controlled by groups and permissions": "",
"A new version (v{{LATEST_VERSION}}) is now available.": "",
@ -51,6 +56,7 @@
"Access Control": "",
"Access Grants": "",
"Access List": "",
"Access updated": "",
"Accessible to all users": "",
"Account": "الحساب",
"Account Activation Pending": "",
@ -76,9 +82,11 @@
"Add content here": "",
"Add Custom Parameter": "",
"Add Custom Prompt": "",
"Add description": "",
"Add Details": "",
"Add Files": "إضافة ملفات",
"Add Image": "",
"Add location": "",
"Add Member": "",
"Add Members": "",
"Add Memory": "إضافة ذكرايات",
@ -114,6 +122,7 @@
"AI": "",
"All": "",
"All chats have been unarchived.": "",
"All day": "",
"All models are now hidden": "",
"All models are now visible": "",
"All models deleted successfully": "",
@ -202,6 +211,7 @@
"Ask a question": "",
"Assistant": "",
"Async Embedding Processing": "",
"At time of event": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "",
@ -276,6 +286,8 @@
"Bypass Web Loader": "",
"Cache Base Model List": "",
"Calendar": "",
"Calendar deleted": "",
"Calendars": "",
"Call": "",
"Call feature is not supported when using Web STT engine": "",
"Camera": "",
@ -412,6 +424,7 @@
"Connect to your own OpenAPI compatible external tool servers.": "",
"Connected ({{type}})": "",
"Connection failed": "",
"Connection lost. Reconnecting...": "",
"Connection successful": "",
"Connection Type": "",
"Connections": "اتصالات",
@ -443,6 +456,7 @@
"Copy Last Response": "",
"Copy link": "",
"Copy Link": "أنسخ الرابط",
"Copy Path": "",
"Copy Prompt": "",
"Copy Share Link": "",
"Copy to clipboard": "",
@ -523,8 +537,11 @@
"Delete All Chats": "حذف جميع الدردشات",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete calendar": "",
"Delete Calendar": "",
"Delete Chat": "حذف المحادثه.",
"Delete chat?": "",
"Delete Event": "",
"Delete File": "",
"Delete folder?": "",
"Delete function?": "",
@ -831,6 +848,10 @@
"Error: A model with the ID '{{modelId}}' already exists. Please select a different ID to proceed.": "",
"Error: Model ID cannot be empty. Please enter a valid ID to proceed.": "",
"Evaluations": "",
"Event created": "",
"Event deleted": "",
"Event title": "",
"Event updated": "",
"Exa API Key": "",
"Example: (&(objectClass=inetOrgPerson)(uid=%s))": "",
"Example: ALL": "",
@ -879,6 +900,7 @@
"Failed to connect to {{URL}} terminal server": "",
"Failed to copy link": "",
"Failed to create API Key.": "فشل في إنشاء مفتاح API.",
"Failed to delete calendar": "",
"Failed to delete note": "",
"Failed to download image": "",
"Failed to extract content from the file: {{error}}": "",
@ -1200,6 +1222,7 @@
"Limit concurrent search queries. 0 = unlimited (default). Set to 1 for sequential execution (recommended for APIs with strict rate limits like Brave free tier).": "",
"Limits the number of concurrent embedding requests. Set to 0 for unlimited.": "",
"List": "",
"List calendars, search, create, update, and delete calendar events": "",
"Listening...": "",
"Live": "",
"Llama.cpp": "",
@ -1210,6 +1233,7 @@
"local": "",
"Local": "",
"Local Task Model": "",
"Location": "",
"Location access not allowed": "",
"Lost": "",
"Low": "",
@ -1277,6 +1301,7 @@
"Model": "",
"Model '{{modelName}}' has been successfully downloaded.": "تم تحميل النموذج '{{modelName}}' بنجاح",
"Model '{{modelTag}}' is already in queue for downloading.": "النموذج '{{modelTag}}' موجود بالفعل في قائمة الانتظار للتحميل",
"Model {{modelId}} not found": "",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "نموذج {{modelName}} غير قادر على الرؤية",
"Model {{name}} is now {{status}}": "نموذج {{name}} هو الآن {{status}}",
@ -1284,6 +1309,7 @@
"Model {{name}} is now visible": "",
"Model accepts file inputs": "",
"Model accepts image inputs": "",
"Model can access Open Terminal for command execution and file management": "",
"Model can execute code and perform calculations": "",
"Model can generate images based on text prompts": "",
"Model can search the web for information": "",
@ -1317,6 +1343,7 @@
"Models Sharing": "",
"Mojeek": "",
"Mojeek Search API Key": "",
"Month": "",
"Monthly": "",
"More": "المزيد",
"More Concise": "",
@ -1335,6 +1362,7 @@
"New Automation": "",
"New Button": "",
"New Chat": "دردشة جديدة",
"New Event": "",
"New File": "",
"New Folder": "",
"New Function": "",
@ -1504,6 +1532,7 @@
"Password": "الباسورد",
"Passwords do not match.": "",
"Paste Large Text as File": "",
"Path copied": "",
"Paused": "",
"PDF document (.pdf)": "PDF ملف (.pdf)",
"PDF Extract Images (OCR)": "PDF أستخرج الصور (OCR)",
@ -1611,6 +1640,7 @@
"Reasoning Effort": "",
"Reasoning Tags": "",
"Recently Used": "",
"Reconnected": "",
"Record": "",
"Record voice": "سجل صوت",
"Redirecting you to Open WebUI Community": "OpenWebUI إعادة توجيهك إلى مجتمع ",
@ -1634,6 +1664,7 @@
"Relevance": "",
"Relevance Threshold": "",
"Remember Dismissal": "",
"Reminder": "",
"Remove": "إزالة",
"Remove {{MODELID}} from list.": "",
"Remove action": "",
@ -1652,6 +1683,7 @@
"Reply to thread...": "",
"Replying to {{NAME}}": "",
"required": "",
"Reranking Batch Size": "",
"Reranking Engine": "",
"Reranking Model": "إعادة تقييم النموذج",
"Reset": "",
@ -1883,7 +1915,15 @@
"Start a new conversation": "",
"Start of the channel": "بداية القناة",
"Start Tag": "",
"Starting in {{count}} minutes_zero": "",
"Starting in {{count}} minutes_one": "",
"Starting in {{count}} minutes_two": "",
"Starting in {{count}} minutes_few": "",
"Starting in {{count}} minutes_many": "",
"Starting in {{count}} minutes_other": "",
"Starting in 1 minute": "",
"Starting kernel...": "",
"Starting now": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
@ -1996,6 +2036,7 @@
"This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.": "",
"This will delete all models including custom models": "",
"This will delete all models including custom models and cannot be undone.": "",
"This will permanently delete the calendar \"{{name}}\" and all its events. This action cannot be undone.": "",
"This will reset the knowledge base and sync all files. Do you wish to continue?": "",
"Thorough explanation": "شرح شامل",
"Thought": "",
@ -2015,6 +2056,7 @@
"Title cannot be an empty string.": "العنوان مطلوب",
"Title Generation": "",
"Title Generation Prompt": "موجه إنشاء العنوان",
"Title is required": "",
"TLS": "",
"To access the available model names for downloading,": "للوصول إلى أسماء الموديلات المتاحة للتنزيل،",
"To access the GGUF models available for downloading,": "للوصول إلى الموديلات GGUF المتاحة للتنزيل،",
@ -2081,6 +2123,7 @@
"Unloads {{FROM_NOW}}": "",
"Unlock mysteries": "",
"Unpin": "",
"Unpin from Sidebar": "",
"Unravel secrets": "",
"Unshare Chat": "",
"Unsupported file type.": "",
@ -2185,6 +2228,7 @@
"WebUI will make requests to \"{{url}}\"": "",
"WebUI will make requests to \"{{url}}/api/chat\"": "",
"WebUI will make requests to \"{{url}}/chat/completions\"": "",
"Week": "",
"Weekly": "",
"What are you trying to achieve?": "",
"What are you working on?": "",
@ -2192,6 +2236,7 @@
"What is shared:": "",
"What's New in": "ما هو الجديد",
"What's on your mind?": "",
"When": "",
"When enabled, the model will respond to each chat message in real-time, generating a response as soon as the user sends a message. This mode is useful for live chat applications, but may impact performance on slower hardware.": "",
"wherever you are": "",
"Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.": "",

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