Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3660bc00fd | ||
|
|
41b48b54aa | ||
|
|
4856ce48be | ||
|
|
0037baeb26 | ||
|
|
c951b4f262 | ||
|
|
39777e35d8 | ||
|
|
15e696691c | ||
|
|
3a21b334cc | ||
|
|
2dbf7b6764 | ||
|
|
4a1064cefd | ||
|
|
315566064a | ||
|
|
3bba1c2270 | ||
|
|
1388f4568b | ||
|
|
d1ef538237 | ||
|
|
fc94118b2d | ||
|
|
8d3133fe28 | ||
|
|
d11e06f1b7 | ||
|
|
841c9045d7 | ||
|
|
f5e110fbee | ||
|
|
e8e9141061 | ||
|
|
d3737176bc | ||
|
|
2d9939ed49 | ||
|
|
c66c273f62 | ||
|
|
e7ba8978c6 | ||
|
|
5b13e3e3f0 | ||
|
|
b0a56375d2 | ||
|
|
8689f7090f | ||
|
|
55535a8965 | ||
|
|
203ec29baf | ||
|
|
9918ab6265 | ||
|
|
8a0018cf96 | ||
|
|
69270e1c9e | ||
|
|
2e71b3fbb8 | ||
|
|
df42d96c95 | ||
|
|
2fa3b84241 | ||
|
|
8854541508 | ||
|
|
793e628ac3 | ||
|
|
a0268e51fc | ||
|
|
f51d2b026f | ||
|
|
0f07af1bb8 | ||
|
|
7d3efb8513 | ||
|
+11 |
adc9076d17 | ||
|
|
413dcae8a2 | ||
|
|
d34d4297ba | ||
|
|
6116c6dca0 | ||
|
|
93931efaa7 | ||
|
|
3ccf263b10 | ||
|
|
75e72ea2f9 | ||
|
|
b94aad2895 | ||
|
|
7bcc0e2e5c | ||
|
|
46ff3abbb8 | ||
|
|
aa51ce482c | ||
|
|
251b80ebec | ||
|
|
4d99baa292 | ||
|
|
3fcad2f627 | ||
|
|
04bd0425ea | ||
|
|
485d689cfd | ||
|
|
85c7373f68 | ||
|
|
11e076817a | ||
|
|
cfd2888545 | ||
|
+5 |
adda20509c | ||
|
|
e1dce99147 | ||
|
|
a938c8ae2e | ||
|
|
5b80932e59 | ||
|
|
2ba6b423aa | ||
|
|
02f9fe7890 | ||
|
|
29f6c72e87 | ||
|
|
bb0e6cb108 | ||
|
|
6700f7bb72 | ||
|
|
1baf73bdd5 | ||
|
|
1d892ce2c5 | ||
|
|
794b97025d | ||
|
|
ee3b82926b | ||
|
|
38a382ef88 | ||
|
|
34146ab60f | ||
|
|
f70b0da156 | ||
|
|
af5628f8ef | ||
|
|
9907c0a25a | ||
|
+3 |
d78c247036 | ||
|
|
ae0827cec0 | ||
|
|
064fdecb67 | ||
|
|
bf4f44ee9c | ||
|
|
212bb68a66 | ||
|
+3 |
aff78e4958 | ||
|
|
ae43562b86 | ||
|
|
13693554f1 | ||
|
|
1a3e5ef4c1 | ||
|
|
26b1a3d7dc | ||
|
|
3ab7b777b1 | ||
|
|
1789303886 | ||
|
|
41107a34ca | ||
|
|
114c99ae2f | ||
|
|
3746339cfc | ||
|
|
7eeff2fdf9 | ||
|
|
55e7c7854b | ||
|
|
c978a788c8 | ||
|
|
3d48596c9e | ||
|
|
072d2000f3 | ||
|
|
9386fc83a3 | ||
|
|
b63da90ae4 | ||
|
|
23ff9943a9 | ||
|
|
33e588cf09 | ||
|
|
005df577fe | ||
|
|
1b4cd705d0 | ||
|
|
8ffc3d746f | ||
|
|
c1202a2327 | ||
|
|
55d1db1f38 | ||
|
|
6082e1adae | ||
|
|
c6763521c0 | ||
|
|
7c398a625a | ||
|
|
cdfcbc4af6 | ||
|
|
4d766a3edf | ||
|
|
d06e6d6ddc | ||
|
|
8b78821ba4 | ||
|
|
552bbcecfa | ||
|
|
f152ad36b3 | ||
|
|
60ea4214aa | ||
|
|
bc4d6eef33 | ||
|
|
cde72dab71 | ||
|
|
ff791b4814 | ||
|
|
7eaecbad5a | ||
|
|
0103d7e82c | ||
|
|
3309f5d9f1 | ||
|
|
e451f8f63b | ||
|
|
6dff85b9d2 | ||
|
|
7e275c1daa | ||
|
|
1c1c8b18e5 | ||
|
|
55a572cd39 | ||
|
|
9adc0c442a | ||
|
|
fd3368c0bf | ||
|
|
ef6d4f2d6c | ||
|
|
6bdc2ffa79 | ||
|
|
b72019db39 | ||
|
|
2977910ffd | ||
|
|
f39f4a86ae | ||
|
|
1dee67b64d | ||
|
|
2a18dc98ac | ||
|
|
1f977d072e | ||
|
|
4754ece4a2 | ||
|
|
5c3edc2539 | ||
|
|
4fe2de7864 | ||
|
|
a32d26e61d | ||
|
|
989d5fd4e2 | ||
|
|
4e6a7baab7 | ||
|
|
cde21b9f6d | ||
|
|
86df8bf27e | ||
|
|
5bc80b145f |
@@ -18,6 +18,7 @@ The most impactful way to contribute to Open WebUI is through well-written bug r
|
||||
|
||||
**Before submitting, make sure you've checked the following:**
|
||||
|
||||
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. If one does not exist, create one first. PRs without a linked issue or discussion may be closed without review.
|
||||
- [ ] **Target branch:** Verify that the pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
|
||||
- [ ] **Description:** Provide a concise description of the changes made in this pull request down below.
|
||||
- [ ] **Changelog:** Ensure a changelog entry following the format of [Keep a Changelog](https://keepachangelog.com/) is added at the bottom of the PR description.
|
||||
|
||||
+140
@@ -5,6 +5,146 @@ 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.5] - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- 🛡️ **Redirect-based SSRF protection.** All outbound HTTP requests now block 3xx redirects by default via a new `AIOHTTP_CLIENT_ALLOW_REDIRECTS` environment variable, preventing redirect-based SSRF where a public URL silently redirects to internal addresses (RFC 1918, loopback, cloud-metadata endpoints). Affected call sites include web fetch, image loading, OAuth discovery, tool server execution, and code interpreter login. [#24491](https://github.com/open-webui/open-webui/pull/24491)
|
||||
- 🛡️ **Iframe content security policy.** Administrators can now configure a Content-Security-Policy for all srcdoc iframes (Artifacts, tool embeds, file previews, citation modals) via the `IFRAME_CSP` environment variable, restricting what LLM-generated or user-uploaded HTML can load and execute inside previews. [Commit](https://github.com/open-webui/open-webui/commit/3bba1c227059a44c7eeefa97b8c69a63bf4f3454)
|
||||
- 🎛️ **Granular markdown rendering controls.** Users can now independently disable Markdown rendering for user messages and assistant responses from Interface settings, preventing unintended formatting when pasting text that contains Markdown-sensitive characters. [Commit](https://github.com/open-webui/open-webui/commit/4a1064cefd6f48a8b3b02cd31f77838c8802b635)
|
||||
- 🔧 **Terminal proxy response headers.** Administrators can now inject custom response headers into terminal proxy responses via the `TERMINAL_PROXY_HEADERS` environment variable (JSON object), enabling deployment-specific security headers like sandbox policies for proxied content. [Commit](https://github.com/open-webui/open-webui/commit/8d3133fe2835122bffaa4f2ce584730bc9c78981)
|
||||
- 🔌 **Channel streaming and tool support.** Mentioning a model in a Channel now streams responses in real time and supports the full chat completion pipeline, including native and default function calling, built-in tools (web search, image generation), user tools, MCP tools, filters, and RAG knowledge injection — the same capabilities available in standard chats.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📝 **Notes create and open reliability.** Creating new notes and opening existing notes no longer fails with a TypeError caused by `is_pinned` being passed to the SQLAlchemy model on create, and passed twice to `NoteResponse` on read. [#24484](https://github.com/open-webui/open-webui/issues/24484), [#24486](https://github.com/open-webui/open-webui/pull/24486)
|
||||
- 🔐 **Skill public sharing permission enforcement.** Creating or updating skills now filters access grants through the `sharing.public_skills` permission, preventing non-admin users from making skills publicly accessible without the required permission. [#24494](https://github.com/open-webui/open-webui/pull/24494)
|
||||
- 🔐 **Calendar public sharing permission enforcement.** Creating or updating calendars now filters access grants through a new `sharing.public_calendars` permission, preventing users from making calendars publicly readable or writable without explicit admin-granted sharing permission. [#24493](https://github.com/open-webui/open-webui/pull/24493)
|
||||
- 🔐 **Feedback user attribution spoofing.** Submitting evaluation feedback can no longer forge the `user_id` field through mass-assignment, preventing authenticated users from attributing ratings to other users and corrupting Elo leaderboard rankings and admin feedback exports. [#24508](https://github.com/open-webui/open-webui/pull/24508)
|
||||
- 🛡️ **Image URL redirect-based SSRF.** Chat messages containing image URLs no longer follow 3xx redirects to internal addresses during base64 conversion, closing the most reachable redirect-based SSRF variant that required no special permissions or feature flags. [#24524](https://github.com/open-webui/open-webui/pull/24524)
|
||||
- 🛡️ **Collection write access on file processing.** The `process_file` and `process_files_batch` retrieval endpoints now enforce collection write-access checks before embedding content, preventing authenticated users from injecting file content into another user's knowledge-base collection. [#24524](https://github.com/open-webui/open-webui/pull/24524)
|
||||
- 🔐 **Tool source code update authorization.** Updating a tool's Python source code now requires `workspace.tools` or `workspace.tools_import` permission, preventing users with only a write-access grant from overwriting executable tool code while still allowing metadata edits. [#24513](https://github.com/open-webui/open-webui/pull/24513)
|
||||
- 🔐 **Channel message ownership enforcement.** Updating or deleting messages in group and DM channels now requires message ownership, preventing channel members from tampering with or silently removing other members' messages. [#24506](https://github.com/open-webui/open-webui/pull/24506)
|
||||
- 🔐 **Channel pin write permission.** Pinning and unpinning messages on standard channels now requires write permission instead of read permission, preventing read-only users from modifying pinned content. [#24521](https://github.com/open-webui/open-webui/pull/24521)
|
||||
- 🛡️ **Image generation URL validation.** Generated image URLs are now validated through `validate_url()` before fetching, aligning the defense-in-depth posture with sibling image-loading paths. [#24518](https://github.com/open-webui/open-webui/pull/24518)
|
||||
- 🔐 **Model params exposure for read-only users.** The per-model API endpoint now strips the `params` dict (including system prompts) from responses to callers without write access, preventing read-only users from viewing admin-curated model configuration. [#24525](https://github.com/open-webui/open-webui/pull/24525)
|
||||
- 🛡️ **URL parser SSRF bypass.** URL validation now rejects backslash, tab, CR, and LF characters that cause urllib and requests/aiohttp to disagree on the target host, closing a parser-confusion SSRF bypass. [#24534](https://github.com/open-webui/open-webui/pull/24534)
|
||||
- 🛡️ **Profile image MIME-type allowlist.** Serving profile images from data URIs now enforces a strict MIME-type allowlist (PNG, JPEG, GIF, WEBP by default, configurable via `PROFILE_IMAGE_ALLOWED_MIME_TYPES`) and sets `X-Content-Type-Options: nosniff`, preventing stored-XSS through SVG or other executable content types. [Commit](https://github.com/open-webui/open-webui/commit/15e696691cad98692c329de62ed8a5bdb3a26d4e)
|
||||
- 🔐 **File ownership in folder and knowledge attachments.** Attaching files to folders or knowledge bases now verifies per-file read access, and folder file lists in chat middleware are filtered to entries the caller can read, preventing unauthorized file content from being injected into RAG context. [Commit](https://github.com/open-webui/open-webui/commit/2dbf7b6764a7922458d3b0139687ad6dcd7596d9)
|
||||
- 🔐 **Shared chat access for owners and admins.** Chat owners can now view and clone their own shared chats without requiring an explicit access grant, and administrators can manage shared chat access controls on any chat. [Commit](https://github.com/open-webui/open-webui/commit/3a21b334cce30226750c5c537345dc51bb8bef17), [Commit](https://github.com/open-webui/open-webui/commit/315566064aedeff071854b023d09e5f1ea0eb950)
|
||||
- 🧵 **Legacy chat history self-healing.** Loading legacy conversations now automatically detects broken parent-link graphs in migrated message records, merges missing messages from the embedded JSON history, and backfills them to the normalized table so future loads use the fast path without data loss. [Commit](https://github.com/open-webui/open-webui/commit/1388f4568b8f508c26542673dd01f1fa049e798a)
|
||||
- 🎛️ **Filter selector reactivity.** Model filter checkboxes now derive state reactively from the current filter list and selected IDs instead of capturing a one-time snapshot at mount, so checkboxes update correctly when model contexts or filter configurations change at runtime. [Commit](https://github.com/open-webui/open-webui/commit/d1ef5382377f590f97a6dbaee88f369e6d7c5f6f)
|
||||
- 🌐 **Portuguese (Brazil) translation updates.** Translations for newly added UI items were added along with a consistency pass across existing entries. [#24503](https://github.com/open-webui/open-webui/pull/24503)
|
||||
|
||||
### Changed
|
||||
|
||||
- 🧹 **Removed unauthenticated retrieval status endpoint.** The unauthenticated `GET /api/v1/retrieval/` status endpoint has been removed as dead code — retrieval configuration is already available through authenticated admin endpoints. [#24497](https://github.com/open-webui/open-webui/pull/24497)
|
||||
- 📋 **PR template issue requirement.** Pull requests now require a linked Issue or Discussion reference, ensuring better traceability for all contributions. PRs without a linked issue or discussion may be closed without review.
|
||||
|
||||
## [0.9.4] - 2026-05-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📜 **Chat scroll position on load.** Opening a chat conversation now reliably scrolls to the bottom of the message history, fixing a regression caused by `content-visibility: auto` where estimated element sizes prevented the initial scroll from reaching the true bottom.
|
||||
|
||||
## [0.9.3] - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- 🔇 **Voice Mode mute control.** Voice Mode now includes a dedicated mute toggle with an "M" shortcut and auto-unmute after assistant playback, so you can prevent accidental interruptions from background noise without leaving the call overlay. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c), [#23832](https://github.com/open-webui/open-webui/issues/23832)
|
||||
- 🚀 **Faster prompt list loading.** Prompt and prompt-tag pages now load much faster for non-admin users, even with large prompt libraries, because accessible prompts are filtered efficiently in a single database query. [#24288](https://github.com/open-webui/open-webui/pull/24288), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
|
||||
- ⚡ **Faster chat history loading.** Chat history maps now load from normalized message records when available, reducing overhead for large conversations while preserving fallback behavior for legacy chats. [Commit](https://github.com/open-webui/open-webui/commit/485d689cfd1ef8b9e7f77cd7b535b8b8747dff1f), [#23159](https://github.com/open-webui/open-webui/pull/23159)
|
||||
- 🗑️ **Delete from conversation menu.** You can now delete the current conversation directly from the chat menu with a confirmation step, so cleanup is faster without searching through the full chat list. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4), [#24329](https://github.com/open-webui/open-webui/issues/24329)
|
||||
- ⬆️ **Scroll to Top shortcut.** Long conversations now include a Scroll to Top action in the chat menu when you are away from the top, making it much faster to jump back to the beginning of a chat. [Commit](https://github.com/open-webui/open-webui/commit/cdfcbc4af6e9aec835b88dc1806a2a46711e6947), [#24133](https://github.com/open-webui/open-webui/issues/24133)
|
||||
- 📅 **Calendar creation flow.** Users can now create calendars from a dedicated modal and a quick-add action in the calendar sidebar, making calendar setup faster from the calendar workspace. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840)
|
||||
- 🧭 **Unified model unload controls.** Administrators can now unload running models from the model selector across supported providers, with loaded-state indicators shown for Ollama and llama.cpp models. [Commit](https://github.com/open-webui/open-webui/commit/4fe2de78643c2213652190d2820f4e8d9f4f89cc)
|
||||
- ⚡ **Health check responsiveness.** Health and readiness probes now avoid blocking database calls and skip sync session commit handling on probe paths, improving responsiveness and reducing false unready transitions during database pressure. [#24380](https://github.com/open-webui/open-webui/pull/24380), [#24384](https://github.com/open-webui/open-webui/pull/24384)
|
||||
- 🎛️ **Playground controls panel.** The Playground now includes a dedicated Controls toggle so you can adjust parameters like temperature and related settings per chat run without changing model-level defaults. [Commit](https://github.com/open-webui/open-webui/commit/c6763521c00f042a28829e33fb6f1b7355054046), [#24103](https://github.com/open-webui/open-webui/issues/24103)
|
||||
- 🎙️ **STT file extension controls.** Administrators can now configure which audio file extensions are accepted for speech-to-text uploads, helping enforce safer and more predictable upload policies. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
|
||||
- 📷 **Remembered call camera selection.** Voice call overlay now remembers your last selected camera and restores it automatically when available, so you do not need to reselect it every time you start voice mode. [Commit](https://github.com/open-webui/open-webui/commit/5c3edc2539ac4d92c4cc2d37079549995203238a), [#24416](https://github.com/open-webui/open-webui/issues/24416)
|
||||
- 👥 **User group prompt variable.** System and template prompts now support the "{{USER_GROUPS}}" variable, which expands to the user’s group memberships so prompts can adapt to role- or access-based context automatically. [Commit](https://github.com/open-webui/open-webui/commit/c1202a23277abb8e7080271a929dcc9d29b67e66), [#24462](https://github.com/open-webui/open-webui/issues/24462)
|
||||
- 🔐 **Public chat sharing permission control.** Administrators can now control whether users are allowed to create publicly shareable chats through a dedicated permission setting. [Commit](https://github.com/open-webui/open-webui/commit/ef6d4f2d6c4b79c7e12e864a4fcb6a57ee84e5d4)
|
||||
- 🔐 **Profile image forwarding control.** Administrators can now disable external profile image URL forwarding with the "ENABLE_PROFILE_IMAGE_URL_FORWARDING" setting to prevent browser metadata leaks to third-party servers. [#24420](https://github.com/open-webui/open-webui/pull/24420)
|
||||
- 🏷️ **Dynamic header template variables.** Administrators can now use chat, message, and user template variables in custom connection and tool server headers so each request can carry per-conversation context automatically. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
|
||||
- 🛂 **MCP OAuth server URL setting.** Static OAuth tool server setups can now define a separate OAuth server URL, making discovery and client registration work when authentication endpoints are hosted separately from the tool server URL. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164), [#24216](https://github.com/open-webui/open-webui/issues/24216)
|
||||
- ⚡ **Faster memory query performance.** Per-user memory lookups and deletions now run much faster at scale because the memory user filter is indexed for existing and new installations. [Commit](https://github.com/open-webui/open-webui/commit/38a382ef888685650135d61dcc8ec0e29eb65573), [#23836](https://github.com/open-webui/open-webui/pull/23836)
|
||||
- 🚀 **Smarter function dependency installs.** Function dependencies are now skipped when they were already preinstalled and unchanged, reducing first-load delays and repeated package installation churn after startup. [Commit](https://github.com/open-webui/open-webui/commit/ae43562b869b24699408e5ab107261a0a8bdb4bc), [#24166](https://github.com/open-webui/open-webui/pull/24166)
|
||||
- 🔎 **Brave LLM Context web search.** Administrators can now choose Brave LLM Context as a web search provider to retrieve richer grounded passages with a configurable context token budget. [Commit](https://github.com/open-webui/open-webui/commit/6700f7bb72d14a3f8dbb72dfa064cae3b3dc29ac), [#24120](https://github.com/open-webui/open-webui/issues/24120)
|
||||
- 🗂️ **Open Terminal date sorting.** Open Terminal now includes sort controls for name and date, with directory-first ordering and modified-time visibility to make file browsing faster. [Commit](https://github.com/open-webui/open-webui/commit/6bdc2ffa79d72daf78981209c9c5292c697cbfe5), [#24425](https://github.com/open-webui/open-webui/issues/24425)
|
||||
- 🎤 **Voice mode prompt toggle.** Administrators can now explicitly enable or disable the Voice Mode custom prompt behavior from Interface settings, giving finer control over how voice replies are guided. [Commit](https://github.com/open-webui/open-webui/commit/17893038869e3a763a8b34457f723b9666804e27)
|
||||
- 🧮 **LaTeX copy shortcut.** You can now click rendered LaTeX expressions to copy the raw formula to your clipboard, making it easier to reuse equations outside chat. [Commit](https://github.com/open-webui/open-webui/commit/064fdecb675c176a04b024c16ce179f4dda45236), [#24244](https://github.com/open-webui/open-webui/pull/24244)
|
||||
- ✨ **Smoother rich text editing.** The message composer now defers formatting toolbar refresh work to the next animation frame, reducing typing jank while formatting controls stay accurate. [Commit](https://github.com/open-webui/open-webui/commit/794b97025d4c56f91d49c9d1ec4775d2ea07b53a), [#24013](https://github.com/open-webui/open-webui/pull/24013)
|
||||
- 🖼️ **Arena model profile images.** Arena models can now reliably display configured profile images instead of falling back to the default icon. [Commit](https://github.com/open-webui/open-webui/commit/1dee67b64d0b34e70bac949682b216c0aaec8152), [#24412](https://github.com/open-webui/open-webui/issues/24412)
|
||||
- 🔄 **Replaceable tool embed updates.** Pipes and Tools can now overwrite previously emitted rich-UI embeds in-place by passing a `replace` flag on the `embeds` event, enabling live dashboards and progress panels that update without stacking duplicate entries.
|
||||
- ✏️ **Assistant response editing and continuation.** You can now edit and restructure assistant output items — including reasoning blocks, tool calls, and text content — from a dedicated editor view, and continue generating from the edited state so the model receives full prior context.
|
||||
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
|
||||
- 🌐 **Translation updates.** Translations for Chinese, Catalan, Filipino, and Korean were enhanced and expanded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🧵 **Background code execution reliability.** Code execution no longer hangs indefinitely when you switch conversations or browser tabs during a run, and disconnected or inactive sessions now fail with a clear timeout error instead of endless processing. [Commit](https://github.com/open-webui/open-webui/commit/552bbcecfae5ae273ab98e2ce3e540d0771aa964), [#24089](https://github.com/open-webui/open-webui/issues/24089)
|
||||
- 🎙️ **Voice recording MIME fallback support.** Voice recording now tries a broader set of browser-supported audio formats and resets halted audio playback cleanly, improving microphone capture reliability in browsers where recording previously failed to start. [Commit](https://github.com/open-webui/open-webui/commit/8ffc3d746f20007e9eb4e3ae4f152f383bc371e1), [#24162](https://github.com/open-webui/open-webui/issues/24162)
|
||||
- 🧠 **Direct-connection task generation reliability.** Title, tags, follow-up, emoji, query, and related task-generation endpoints now work correctly when chats use direct-connection models instead of failing with model-not-found errors. [Commit](https://github.com/open-webui/open-webui/commit/1b4cd705d0b9a51a5e3a7851ec012fb3141eb0a9), [Commit](https://github.com/open-webui/open-webui/commit/005df577fec16733a64edbec8a1b46f42f4e9a43), [#24092](https://github.com/open-webui/open-webui/issues/24092)
|
||||
- 🔧 **Parameterized URL tool readiness.** New chats now wait for model defaults to finish applying before auto-submit, preventing early requests that can miss configured external tools. [Commit](https://github.com/open-webui/open-webui/commit/212bb68a66435dc1803a6d67cb4ea584d3455fb7), [#24176](https://github.com/open-webui/open-webui/issues/24176)
|
||||
- 🚦 **MCP cleanup response reliability.** Successful native MCP tool calls no longer get replaced by a 500 "No response returned" error during cleanup, so valid chat responses are now returned consistently. [#24105](https://github.com/open-webui/open-webui/pull/24105)
|
||||
- 🧵 **Active task state recovery.** Chat input no longer stays blocked by unrelated background tasks after a response is already complete, and interrupted assistant replies are now marked done more reliably. [Commit](https://github.com/open-webui/open-webui/commit/04bd0425ead28185bcd124e77892e31209a6e15b), [#23264](https://github.com/open-webui/open-webui/pull/23264)
|
||||
- 📌 **Per-user note pinning behavior.** Pinned notes are now tracked per user instead of with a shared note-level flag, so one person’s pin changes no longer affect everyone else. [Commit](https://github.com/open-webui/open-webui/commit/33e588cf09b294f0abe08b9566efa8545a7dbf92)
|
||||
- 🧱 **Custom header value coercion.** Custom header values are now converted to text before requests are sent, preventing request failures when non-text values are configured. [Commit](https://github.com/open-webui/open-webui/commit/9907c0a25ae830d134af70022238715f834d20c6), [#24164](https://github.com/open-webui/open-webui/pull/24164)
|
||||
- 🔗 **HTTP share link copy fallback.** Copy Link now works reliably on HTTP deployments by using a selection-based fallback when secure clipboard APIs are unavailable. [Commit](https://github.com/open-webui/open-webui/commit/f70b0da1563ffa0a8daecbe71cbc30fd8cf834c4), [#24135](https://github.com/open-webui/open-webui/issues/24135)
|
||||
- 🧵 **Regeneration loading lock recovery.** Chats no longer get stuck in a permanent loading state after failed regenerations because invalid message-tree references are repaired before rendering. [Commit](https://github.com/open-webui/open-webui/commit/ee3b82926b37843f2771c6a8d432781a557ea96a), [#24424](https://github.com/open-webui/open-webui/issues/24424)
|
||||
- 📸 **Complete chat image capture.** Downloaded chat snapshots now include all messages more reliably through visibility overrides and layout timing improvements during capture. [Commit](https://github.com/open-webui/open-webui/commit/34146ab60f5dc1a2f8bdda8e61ce02797233a25d), [Commit](https://github.com/open-webui/open-webui/commit/1baf73bdd56f4e5ded12a4bd3c168f4d2a70b840), [#24088](https://github.com/open-webui/open-webui/issues/24088)
|
||||
- 🗓️ **Calendar deletion lock handling.** Calendar deletion now avoids SQLite write-lock contention by revoking calendar access grants in a separate transaction after calendar and event removal. [Commit](https://github.com/open-webui/open-webui/commit/1d892ce2c513c4d933c902de5e5d76c317a06dd2)
|
||||
- 🧩 **Filter and internal tool coexistence.** Internal tools now remain available when filters add provider-native tools, so filter-added tools no longer replace the built-in tool set during request processing. [Commit](https://github.com/open-webui/open-webui/commit/02f9fe78907c2ecf6f1d93646cbfa2173409bbe8), [#24237](https://github.com/open-webui/open-webui/issues/24237)
|
||||
- 🛠️ **OpenAPI tool spec compatibility.** OpenAPI tool integrations now handle null or non-operation path entries more safely and parse path-level parameters consistently, preventing crashes and improving tool execution reliability across imperfect OpenAPI specs. [Commit](https://github.com/open-webui/open-webui/commit/2ba6b423aa0c9c800bd96cb638c6ade867cac0f6), [Commit](https://github.com/open-webui/open-webui/commit/5b80932e5951786bb348b91589e8d87753f18905), [#24376](https://github.com/open-webui/open-webui/pull/24376)
|
||||
- 🧰 **OpenAPI tool schema parsing.** OpenAPI tool imports now ignore non-method path item fields and correctly resolve nested composition schemas, preventing invalid tool parsing for compatible specs. [Commit](https://github.com/open-webui/open-webui/commit/85c7373f68ac3e39a9cd37e63b6926b13fb8b8cc), [#23254](https://github.com/open-webui/open-webui/pull/23254)
|
||||
- 🌍 **Web search proxy compatibility.** DuckDuckGo search now respects configured proxy environments more reliably, and trust-env behavior defaults to enabled so proxied web loading does not fail unexpectedly. [Commit](https://github.com/open-webui/open-webui/commit/bb0e6cb1085aa3c3da66a5f5ea1cecff7e9b5297), [#23810](https://github.com/open-webui/open-webui/pull/23810)
|
||||
- 🧾 **Final markdown render flush.** Streaming markdown now forces an immediate final parse when generation completes, preventing stale or partially rendered final output. [Commit](https://github.com/open-webui/open-webui/commit/29f6c72e879d67f23021938e24a21914cc9fb120), [#24088](https://github.com/open-webui/open-webui/issues/24088)
|
||||
- 🛡️ **Webhook avatar URL validation.** Channel webhook profile image URLs are now validated before saving, preventing invalid or unsafe avatar URLs from being accepted. [#24370](https://github.com/open-webui/open-webui/pull/24370)
|
||||
- 📝 **System prompt editor scroll stability.** Editing large system prompts no longer jumps the page back to the top, so you can continue editing long model prompts without losing your place. [Commit](https://github.com/open-webui/open-webui/commit/c978a788c8315e37357c93c1b605a2831fc77485), [#23999](https://github.com/open-webui/open-webui/issues/23999)
|
||||
- 🔎 **Knowledge content search matching.** Knowledge file search now matches both file titles and file content, so relevant files are easier to find even when the keyword is not in the filename. [Commit](https://github.com/open-webui/open-webui/commit/11e076817ae5db34621ce03136353248f7377d97), [#24297](https://github.com/open-webui/open-webui/pull/24297)
|
||||
- ⚡ **Faster prompt tag loading.** Prompt tag filters now load much faster for non-admin users by fetching only accessible tags directly, avoiding per-prompt permission checks and unnecessary prompt data loading. [#24287](https://github.com/open-webui/open-webui/pull/24287), [#24258](https://github.com/open-webui/open-webui/discussions/24258)
|
||||
- 🧾 **Citation overflow badge readability.** Citation overflow badges now keep multi-digit counts readable in a single compact bubble, preventing wrapped or cramped display when many sources are attached. [Commit](https://github.com/open-webui/open-webui/commit/23ff9943a9fc8c314100fa074157853fbece1a55), [#24391](https://github.com/open-webui/open-webui/pull/24391)
|
||||
- 🌐 **Yandex result parsing guard.** Yandex web search no longer fails when some XML fields are missing in individual results, so valid search responses continue to return usable sources instead of dropping to no results. [Commit](https://github.com/open-webui/open-webui/commit/9386fc83a3eff3e55cc157ac8c15c337e3d822c1), [#24243](https://github.com/open-webui/open-webui/issues/24243)
|
||||
- 🎧 **Safer voice transcription uploads.** Empty or failed voice conversions are now rejected with a clear error instead of continuing as malformed audio, reducing failed transcription attempts from corrupted or near-empty recordings. [Commit](https://github.com/open-webui/open-webui/commit/072d2000f35a9f7b96342fa9bb28f925a92e7b4c)
|
||||
- 🎚️ **Safer chunked STT processing.** Chunked transcription now limits worker concurrency when no external STT engine is configured, reducing failed transcription behavior caused by overly parallel local processing. [Commit](https://github.com/open-webui/open-webui/commit/55e7c7854bba5182803239c903a0ac2d14426a4c)
|
||||
- 📈 **Imported chat analytics coverage.** Imported ChatGPT conversations now carry proper model and timestamp metadata and reliably write imported messages into analytics-backed storage, so imported chats are reflected correctly in Admin Analytics totals and model usage views. [Commit](https://github.com/open-webui/open-webui/commit/4d766a3edfa116abcefe7168f1d1284683b860b2), [#24263](https://github.com/open-webui/open-webui/issues/24263)
|
||||
- 📎 **Knowledge collection persistence.** Knowledge collections selected with the chat input selector now remain attached after reloads and chat switches, so attached context no longer disappears between sessions. [Commit](https://github.com/open-webui/open-webui/commit/7c398a625a8d51f79d80217f6d329fc30c72b782), [#24142](https://github.com/open-webui/open-webui/issues/24142)
|
||||
- 🧹 **Embedding model name trimming.** Embedding model names entered in Documents settings now automatically trim surrounding whitespace, preventing silent embedding failures caused by accidental trailing spaces. [Commit](https://github.com/open-webui/open-webui/commit/6082e1adaebc8aa3e7f55265c8dc2dbe130c0446), [#24090](https://github.com/open-webui/open-webui/issues/24090)
|
||||
- 🔊 **PCM TTS playback compatibility.** Text-to-speech audio returned as PCM is now converted to MP3 before delivery, so speech playback works correctly with providers that return raw PCM audio. [Commit](https://github.com/open-webui/open-webui/commit/ff791b4814fc1453df2235ea78016d7015aa6806), [#24143](https://github.com/open-webui/open-webui/issues/24143)
|
||||
- 🪟 **Windows PostgreSQL startup compatibility.** Windows pip installs using PostgreSQL now start reliably with psycopg async by using a compatible event loop policy instead of the default Proactor loop. [Commit](https://github.com/open-webui/open-webui/commit/7eaecbad5a0913ed04ca3bc10c930bb051dd2bd9), [#24152](https://github.com/open-webui/open-webui/issues/24152)
|
||||
- ⏱️ **MCP OAuth timeout control.** OAuth token exchanges for MCP tool server connections now respect the configurable client timeout setting, reducing callback failures with slower providers. [Commit](https://github.com/open-webui/open-webui/commit/cde72dab71671645e119564ca9747ce25dd590ad), [#24138](https://github.com/open-webui/open-webui/issues/24138)
|
||||
- 📄 **PDF text search restoration.** PDF previews now include a proper text layer so browser text selection and find-in-page search work again instead of rendering only image-like pages. [Commit](https://github.com/open-webui/open-webui/commit/bc4d6eef33dcb92719b07483cdb1d63ebf250721), [#24149](https://github.com/open-webui/open-webui/issues/24149)
|
||||
- 🔑 **Android password autofill support.** Password inputs now expose the expected field name metadata, improving password manager autofill reliability on Android login pages. [Commit](https://github.com/open-webui/open-webui/commit/60ea4214aa42f1ad22142f1a43535007a2293d16), [#24137](https://github.com/open-webui/open-webui/issues/24137)
|
||||
- 🎤 **Non-blocking STT processing.** Speech-to-text transcription no longer blocks the server event loop during both live transcription and uploaded audio file processing, so other users can continue using chats and live connections under concurrent load. [#24338](https://github.com/open-webui/open-webui/pull/24338), [#24379](https://github.com/open-webui/open-webui/pull/24379), [#24169](https://github.com/open-webui/open-webui/issues/24169)
|
||||
- 🌐 **SearXNG language parameter handling.** Web searches now send clean multi-language values without trailing separators, so SearXNG requests no longer fail when multiple languages are selected. [Commit](https://github.com/open-webui/open-webui/commit/6dff85b9d205cfc4bc2845dac40909b8d859910c), [#24198](https://github.com/open-webui/open-webui/issues/24198)
|
||||
- 📂 **File modal open-link behavior.** Clicking a file name in the file details modal now opens the correct file content in a new tab for uploaded file items instead of failing to open. [#24125](https://github.com/open-webui/open-webui/pull/24125)
|
||||
- 📎 **Chat attachment display recovery.** Files attached by chat tools now appear reliably in assistant responses, including non-image file attachments that were previously hidden. [Commit](https://github.com/open-webui/open-webui/commit/7eeff2fdf945024585a01b72071a61971afc844d), [#24332](https://github.com/open-webui/open-webui/pull/24332)
|
||||
- 🧱 **Channel embed rendering guard.** Channel message embeds now appear only for model-generated messages and are suppressed in reply previews, preventing unintended embed expansion in regular user posts. [Commit](https://github.com/open-webui/open-webui/commit/e1dce9914745de9b4d2c67b1deddde3472ce4dfa)
|
||||
- 🛡️ **Safer image URL handling.** Untrusted external image URLs are now blocked in profile and rich-text image rendering paths, preventing unintended client-side requests to attacker-controlled domains. [#24420](https://github.com/open-webui/open-webui/pull/24420)
|
||||
- 🛡️ **Sanitized spreadsheet HTML previews.** Spreadsheet previews now sanitize generated HTML before rendering, reducing the risk of unsafe content being executed when opening office files in chat and file modals. [#24468](https://github.com/open-webui/open-webui/pull/24468)
|
||||
- 🧰 **Multi-worker tool update consistency.** Updated tool code now refreshes correctly across workers without requiring a full service restart, so chats no longer run stale tool versions after edits. [Commit](https://github.com/open-webui/open-webui/commit/3309f5d9f11f521c0ee97b64c59a83e3cf390bde), [#24400](https://github.com/open-webui/open-webui/issues/24400), [#24433](https://github.com/open-webui/open-webui/pull/24433)
|
||||
- 🧩 **Default model metadata env parsing.** The "DEFAULT_MODEL_METADATA" environment setting is now parsed and applied correctly, including when persistent config is disabled, so configured model capability defaults are no longer ignored at startup. [Commit](https://github.com/open-webui/open-webui/commit/0103d7e82cccbd5c4b1c8daabcb3e5160fa74a97), [#24319](https://github.com/open-webui/open-webui/issues/24319)
|
||||
- 🔄 **Config import and Redis consistency.** Imported settings now remain effective after import because configuration values are immediately synchronized to Redis, preventing stale cached values from overriding imported permissions and settings. [Commit](https://github.com/open-webui/open-webui/commit/55a572cd398c9b4e6118728f8f129941437aa225), [Commit](https://github.com/open-webui/open-webui/commit/1c1c8b18e5cc90ca3c6961a4c193a4363febbc83), [#24346](https://github.com/open-webui/open-webui/issues/24346)
|
||||
- 🔔 **LDAP signup webhook parity.** New accounts created through LDAP now trigger the same signup webhook notifications as password and OAuth signups, so downstream provisioning and audit automations receive consistent events. [Commit](https://github.com/open-webui/open-webui/commit/fd3368c0bff168417e3c49ffd73491c344702339), [#24377](https://github.com/open-webui/open-webui/issues/24377)
|
||||
- 🦆 **DDGS auto-backend compatibility.** Web search now handles DDGS automatic backend selection correctly and safely falls back on empty or rate-limited responses, preventing search failures in newer DDGS versions. [Commit](https://github.com/open-webui/open-webui/commit/9adc0c442a57eaa88a5f30c2b2cb393623154e20), [#24188](https://github.com/open-webui/open-webui/issues/24188)
|
||||
- 🤖 **Automation update tool reliability.** Updating existing automations in chat now works correctly instead of failing with a missing method error. [Commit](https://github.com/open-webui/open-webui/commit/f39f4a86aedc2769d8268670a020b1f3c16776dd), [#24405](https://github.com/open-webui/open-webui/issues/24405#issuecomment-4408011166)
|
||||
- 📅 **Calendar event permission checks.** Calendar event update and delete actions now handle ownership and access checks more reliably, returning clean access-denied results when appropriate. [Commit](https://github.com/open-webui/open-webui/commit/2977910ffd9d2369dfa504aa6ab12745b3dbd19a)
|
||||
- 🛡️ **Safer cached file delivery.** Cached files that are not recognized as image, audio, or video now download as attachments instead of rendering inline, reducing the risk of unsafe browser content handling. [Commit](https://github.com/open-webui/open-webui/commit/4754ece4a2de5bba85a1d53af2dc8d24fdfb58be)
|
||||
- 📊 **Streaming token analytics accuracy.** Admin Analytics now records and aggregates token usage correctly for streaming chats across Responses API and OpenAI-compatible providers, including fallback handling for provider usage formats that use prompt and completion token keys. [Commit](https://github.com/open-webui/open-webui/commit/989d5fd4e2ce285edf4475a1e13f0981a78d3821), [Commit](https://github.com/open-webui/open-webui/commit/a32d26e61d24d9f63650faed5cb8909ed90af661), [#24217](https://github.com/open-webui/open-webui/issues/24217), [#24294](https://github.com/open-webui/open-webui/issues/24294), [#24241](https://github.com/open-webui/open-webui/issues/24241)
|
||||
- 🔗 **Admin shared chat links.** Admin users can now open and clone shared chat links reliably without 401 errors because shared links are now resolved by share ID first, with safe fallback behavior for direct chat ID access. [Commit](https://github.com/open-webui/open-webui/commit/cde21b9f6dc11575a668484f42440824ec5a4fae), [#24311](https://github.com/open-webui/open-webui/issues/24311), [#24096](https://github.com/open-webui/open-webui/issues/24096)
|
||||
- 💾 **Chat settings persistence.** System prompts and other chat-level settings now persist correctly after creating a new chat and reloading, preventing prompt loss in affected conversations. [Commit](https://github.com/open-webui/open-webui/commit/86df8bf27e1b84abbe2eeedcc8650df59c7d23d6), [#24193](https://github.com/open-webui/open-webui/issues/24193), [#24270](https://github.com/open-webui/open-webui/issues/24270)
|
||||
- 💾 **Chat control autosave persistence.** Changes to chat controls like system prompt, parameters, and attached files are now autosaved on existing chats, so edits are no longer lost when you refresh or navigate away before sending a message. [Commit](https://github.com/open-webui/open-webui/commit/a938c8ae2e45a00d2f06151fdaeaee94e54a8095), [#23897](https://github.com/open-webui/open-webui/pull/23897)
|
||||
- ☁️ **OneDrive option visibility.** OneDrive personal and business upload options now appear only when their respective client IDs are configured, preventing unavailable options from showing in attachment menus. [Commit](https://github.com/open-webui/open-webui/commit/b72019db393a658ca0ceecdcc59b70f6cc5dcd40), [#24411](https://github.com/open-webui/open-webui/issues/24411)
|
||||
- 🧠 **Reasoning content leakage prevention.** Tool-call round-trip messages no longer wrap reasoning text in `<think>` tags inside the content field, preventing raw markup from leaking into chat output for models whose templates don't strip think tags (e.g. Gemma 4). [#23844](https://github.com/open-webui/open-webui/issues/23844)
|
||||
- 🖥️ **Terminal sidebar auto-open guard.** The terminal sidebar no longer auto-opens on chat load when OpenTerminal is disabled, because stale terminal IDs saved on models or in localStorage are now validated against available terminal servers before use.
|
||||
- 🔁 **Single-confirmation connection deletion.** Deleting OpenAI, Ollama, tool server, and terminal server connections now shows exactly one confirmation dialog instead of two, because redundant outer confirmation wrappers were removed from all connection components.
|
||||
- 🧵 **Reliable background task cleanup.** The chat task lifecycle now deregisters completed tasks before checking for remaining siblings, eliminating the off-by-one timing issue that could leave the stop button stuck or dismiss the sidebar activity spinner too early.
|
||||
|
||||
### 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.
|
||||
- 🚪 **Signout request method.** The signout endpoint now requires POST instead of GET, so custom clients and integrations must update logout calls accordingly. [#24420](https://github.com/open-webui/open-webui/pull/24420)
|
||||
|
||||
## [0.9.2] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ ENV APP_BUILD_HASH=${BUILD_HASH}
|
||||
RUN npm run build
|
||||
|
||||
######## WebUI backend ########
|
||||
FROM python:3.11.14-slim-bookworm AS base
|
||||
FROM python:3.11-slim-bookworm AS base
|
||||
|
||||
# Use args
|
||||
ARG USE_CUDA
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@@ -68,12 +69,18 @@ def serve(
|
||||
import open_webui.main # noqa: F401
|
||||
from open_webui.env import UVICORN_WORKERS # Import the workers setting
|
||||
|
||||
# On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop,
|
||||
# which is incompatible with psycopg v3 async. Setting loop='none' lets
|
||||
# asyncio.run() respect the WindowsSelectorEventLoopPolicy set in db.py.
|
||||
loop = 'none' if sys.platform == 'win32' else 'auto'
|
||||
|
||||
uvicorn.run(
|
||||
'open_webui.main:app',
|
||||
host=host,
|
||||
port=port,
|
||||
forwarded_allow_ips='*',
|
||||
workers=UVICORN_WORKERS,
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -328,6 +328,17 @@ class AppConfig:
|
||||
except Exception as e:
|
||||
log.error(f'Failed to async-persist config key {key}: {e}')
|
||||
|
||||
def _sync_to_redis(self):
|
||||
"""Push all in-memory config values to Redis, e.g. after a bulk import."""
|
||||
if not self._redis or not ENABLE_PERSISTENT_CONFIG:
|
||||
return
|
||||
for key, pc in self._state.items():
|
||||
redis_key = f'{self._redis_key_prefix}:config:{key}'
|
||||
try:
|
||||
self._redis.set(redis_key, json.dumps(pc.value))
|
||||
except Exception as e:
|
||||
log.error(f'Failed to sync config key {key} to Redis: {e}')
|
||||
|
||||
def __getattr__(self, key):
|
||||
if key not in self._state:
|
||||
raise AttributeError(f"Config key '{key}' not found")
|
||||
@@ -1197,6 +1208,12 @@ TOOL_SERVER_CONNECTIONS = PersistentConfig(
|
||||
tool_server_connections,
|
||||
)
|
||||
|
||||
OAUTH_CLIENT_TIMEOUT = PersistentConfig(
|
||||
'OAUTH_CLIENT_TIMEOUT',
|
||||
'oauth.client.timeout',
|
||||
os.environ.get('OAUTH_CLIENT_TIMEOUT', ''),
|
||||
)
|
||||
|
||||
####################################
|
||||
# TERMINAL_SERVER
|
||||
####################################
|
||||
@@ -1209,6 +1226,11 @@ TERMINAL_SERVER_CONNECTIONS = PersistentConfig(
|
||||
terminal_server_connections,
|
||||
)
|
||||
|
||||
try:
|
||||
TERMINAL_PROXY_HEADERS = json.loads(os.environ.get('TERMINAL_PROXY_HEADERS', '{}'))
|
||||
except Exception:
|
||||
TERMINAL_PROXY_HEADERS = {}
|
||||
|
||||
####################################
|
||||
# WEBUI
|
||||
####################################
|
||||
@@ -1299,10 +1321,16 @@ MODEL_ORDER_LIST = PersistentConfig(
|
||||
[],
|
||||
)
|
||||
|
||||
try:
|
||||
default_model_metadata = json.loads(os.environ.get('DEFAULT_MODEL_METADATA', '{}'))
|
||||
except Exception as e:
|
||||
log.exception(f'Error loading DEFAULT_MODEL_METADATA: {e}')
|
||||
default_model_metadata = {}
|
||||
|
||||
DEFAULT_MODEL_METADATA = PersistentConfig(
|
||||
'DEFAULT_MODEL_METADATA',
|
||||
'models.default_metadata',
|
||||
{},
|
||||
default_model_metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1348,6 +1376,7 @@ RESPONSE_WATERMARK = PersistentConfig(
|
||||
os.environ.get('RESPONSE_WATERMARK', ''),
|
||||
)
|
||||
|
||||
IFRAME_CSP = os.environ.get('IFRAME_CSP', '')
|
||||
|
||||
USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS = (
|
||||
os.environ.get('USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS', 'False').lower() == 'true'
|
||||
@@ -1442,6 +1471,10 @@ USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
|
||||
os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
|
||||
os.environ.get('USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
|
||||
os.environ.get('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
|
||||
)
|
||||
@@ -1477,6 +1510,10 @@ USER_PERMISSIONS_CHAT_EDIT = os.environ.get('USER_PERMISSIONS_CHAT_EDIT', 'True'
|
||||
|
||||
USER_PERMISSIONS_CHAT_SHARE = os.environ.get('USER_PERMISSIONS_CHAT_SHARE', 'True').lower() == 'true'
|
||||
|
||||
USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING = (
|
||||
os.environ.get('USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_EXPORT = os.environ.get('USER_PERMISSIONS_CHAT_EXPORT', 'True').lower() == 'true'
|
||||
|
||||
USER_PERMISSIONS_CHAT_STT = os.environ.get('USER_PERMISSIONS_CHAT_STT', 'True').lower() == 'true'
|
||||
@@ -1557,6 +1594,8 @@ DEFAULT_USER_PERMISSIONS = {
|
||||
'public_skills': USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING,
|
||||
'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING,
|
||||
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
||||
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
|
||||
'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING,
|
||||
},
|
||||
'access_grants': {
|
||||
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
|
||||
@@ -2051,6 +2090,12 @@ VOICE_MODE_PROMPT_TEMPLATE = PersistentConfig(
|
||||
os.environ.get('VOICE_MODE_PROMPT_TEMPLATE', ''),
|
||||
)
|
||||
|
||||
ENABLE_VOICE_MODE_PROMPT = PersistentConfig(
|
||||
'ENABLE_VOICE_MODE_PROMPT',
|
||||
'task.voice.prompt.enable',
|
||||
os.environ.get('ENABLE_VOICE_MODE_PROMPT', 'True').lower() == 'true',
|
||||
)
|
||||
|
||||
DEFAULT_VOICE_MODE_PROMPT_TEMPLATE = """You are a friendly, concise voice assistant.
|
||||
|
||||
Everything you say will be spoken aloud.
|
||||
@@ -2627,13 +2672,17 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
|
||||
)
|
||||
|
||||
|
||||
ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true'
|
||||
ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true'
|
||||
|
||||
ONEDRIVE_CLIENT_ID = os.environ.get('ONEDRIVE_CLIENT_ID', '')
|
||||
ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get('ONEDRIVE_CLIENT_ID_PERSONAL', ONEDRIVE_CLIENT_ID)
|
||||
ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get('ONEDRIVE_CLIENT_ID_BUSINESS', ONEDRIVE_CLIENT_ID)
|
||||
|
||||
ENABLE_ONEDRIVE_PERSONAL = os.environ.get('ENABLE_ONEDRIVE_PERSONAL', 'True').lower() == 'true' and bool(
|
||||
ONEDRIVE_CLIENT_ID_PERSONAL
|
||||
)
|
||||
ENABLE_ONEDRIVE_BUSINESS = os.environ.get('ENABLE_ONEDRIVE_BUSINESS', 'True').lower() == 'true' and bool(
|
||||
ONEDRIVE_CLIENT_ID_BUSINESS
|
||||
)
|
||||
|
||||
ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
|
||||
'ONEDRIVE_SHAREPOINT_URL',
|
||||
'onedrive.sharepoint_url',
|
||||
@@ -3252,7 +3301,7 @@ ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
|
||||
WEB_SEARCH_TRUST_ENV = PersistentConfig(
|
||||
'WEB_SEARCH_TRUST_ENV',
|
||||
'rag.web.search.trust_env',
|
||||
os.getenv('WEB_SEARCH_TRUST_ENV', 'False').lower() == 'true',
|
||||
os.getenv('WEB_SEARCH_TRUST_ENV', 'True').lower() == 'true',
|
||||
)
|
||||
|
||||
|
||||
@@ -3310,6 +3359,12 @@ BRAVE_SEARCH_API_KEY = PersistentConfig(
|
||||
os.getenv('BRAVE_SEARCH_API_KEY', ''),
|
||||
)
|
||||
|
||||
BRAVE_SEARCH_CONTEXT_TOKENS = PersistentConfig(
|
||||
'BRAVE_SEARCH_CONTEXT_TOKENS',
|
||||
'rag.web.search.brave_search_context_tokens',
|
||||
int(os.getenv('BRAVE_SEARCH_CONTEXT_TOKENS', '8192')),
|
||||
)
|
||||
|
||||
KAGI_SEARCH_API_KEY = PersistentConfig(
|
||||
'KAGI_SEARCH_API_KEY',
|
||||
'rag.web.search.kagi_search_api_key',
|
||||
@@ -3947,6 +4002,19 @@ AUDIO_STT_SUPPORTED_CONTENT_TYPES = PersistentConfig(
|
||||
],
|
||||
)
|
||||
|
||||
AUDIO_STT_ALLOWED_EXTENSIONS = PersistentConfig(
|
||||
'AUDIO_STT_ALLOWED_EXTENSIONS',
|
||||
'audio.stt.allowed_extensions',
|
||||
[
|
||||
ext.strip()
|
||||
for ext in os.environ.get(
|
||||
'AUDIO_STT_ALLOWED_EXTENSIONS',
|
||||
'mp3,wav,m4a,webm,ogg,flac,mp4,mpga,mpeg',
|
||||
).split(',')
|
||||
if ext.strip()
|
||||
],
|
||||
)
|
||||
|
||||
AUDIO_STT_AZURE_API_KEY = PersistentConfig(
|
||||
'AUDIO_STT_AZURE_API_KEY',
|
||||
'audio.stt.azure.api_key',
|
||||
|
||||
+39
-11
@@ -1,21 +1,21 @@
|
||||
import datetime as dt
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
import re
|
||||
|
||||
|
||||
import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
####################################
|
||||
@@ -43,7 +43,8 @@ except ImportError:
|
||||
|
||||
DOCKER = os.environ.get('DOCKER', 'False').lower() == 'true'
|
||||
|
||||
# device type embedding models - "cpu" (default), "cuda" (nvidia gpu required) or "mps" (apple silicon) - choosing this right can lead to better performance
|
||||
# device type for embedding models - "cpu" (default), "cuda" (nvidia gpu required), or "mps" (apple silicon)
|
||||
# choosing this correctly can lead to better performance
|
||||
USE_CUDA = os.environ.get('USE_CUDA_DOCKER', 'false')
|
||||
|
||||
if USE_CUDA.lower() == 'true':
|
||||
@@ -87,7 +88,7 @@ class JSONFormatter(logging.Formatter):
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry: dict[str, Any] = {
|
||||
'ts': datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(timespec='milliseconds'),
|
||||
'ts': dt.datetime.fromtimestamp(record.created, tz=dt.UTC).isoformat(timespec='milliseconds'),
|
||||
'level': _LEVEL_MAP.get(record.levelname, record.levelname.lower()),
|
||||
'msg': record.getMessage(),
|
||||
'caller': record.name,
|
||||
@@ -180,7 +181,7 @@ def parse_section(section):
|
||||
|
||||
try:
|
||||
changelog_path = BASE_DIR / 'CHANGELOG.md'
|
||||
with open(str(changelog_path.absolute()), 'r', encoding='utf8') as file:
|
||||
with open(str(changelog_path.absolute()), encoding='utf8') as file:
|
||||
changelog_content = file.read()
|
||||
|
||||
except Exception:
|
||||
@@ -248,6 +249,26 @@ ENABLE_STAR_SESSIONS_MIDDLEWARE = os.environ.get('ENABLE_STAR_SESSIONS_MIDDLEWAR
|
||||
|
||||
ENABLE_EASTER_EGGS = os.environ.get('ENABLE_EASTER_EGGS', 'True').lower() == 'true'
|
||||
|
||||
####################################
|
||||
# ENABLE_PROFILE_IMAGE_URL_FORWARDING
|
||||
####################################
|
||||
|
||||
# When True (default), the user and model profile-image endpoints
|
||||
# honour external http(s) URLs stored in profile_image_url by issuing a
|
||||
# 302 redirect to the original origin. Set to False to suppress the
|
||||
# redirect (prevents client-side IP/UA/Referer leaks to attacker-
|
||||
# controlled origins) and fall through to the default image instead.
|
||||
ENABLE_PROFILE_IMAGE_URL_FORWARDING = os.environ.get('ENABLE_PROFILE_IMAGE_URL_FORWARDING', 'True').lower() == 'true'
|
||||
|
||||
PROFILE_IMAGE_ALLOWED_MIME_TYPES = frozenset(
|
||||
t.strip()
|
||||
for t in os.environ.get(
|
||||
'PROFILE_IMAGE_ALLOWED_MIME_TYPES',
|
||||
'image/png,image/jpeg,image/gif,image/webp',
|
||||
).split(',')
|
||||
if t.strip()
|
||||
)
|
||||
|
||||
####################################
|
||||
# WEBUI_BUILD_HASH
|
||||
####################################
|
||||
@@ -339,7 +360,7 @@ DATABASE_SCHEMA = os.environ.get('DATABASE_SCHEMA', None)
|
||||
|
||||
DATABASE_POOL_SIZE = os.environ.get('DATABASE_POOL_SIZE', None)
|
||||
|
||||
if DATABASE_POOL_SIZE != None:
|
||||
if DATABASE_POOL_SIZE is not None:
|
||||
try:
|
||||
DATABASE_POOL_SIZE = int(DATABASE_POOL_SIZE)
|
||||
except Exception:
|
||||
@@ -652,7 +673,7 @@ if LICENSE_PUBLIC_KEY:
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
{LICENSE_PUBLIC_KEY}
|
||||
-----END PUBLIC KEY-----
|
||||
""".encode('utf-8')
|
||||
""".encode()
|
||||
)
|
||||
|
||||
|
||||
@@ -812,6 +833,13 @@ else:
|
||||
|
||||
AIOHTTP_CLIENT_SESSION_SSL = os.environ.get('AIOHTTP_CLIENT_SESSION_SSL', 'True').lower() == 'true'
|
||||
|
||||
# When False (default), outbound HTTP requests do not follow 3xx redirects.
|
||||
# This prevents redirect-based SSRF where a public URL 302-redirects to an
|
||||
# internal address (RFC 1918, loopback, cloud-metadata 169.254.169.254).
|
||||
# Set to True only if your deployment requires redirect following and you
|
||||
# have other SSRF protections in place (e.g. egress firewall).
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS = os.environ.get('AIOHTTP_CLIENT_ALLOW_REDIRECTS', 'False').lower() == 'true'
|
||||
|
||||
AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
|
||||
'AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST',
|
||||
os.environ.get('AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST', '10'),
|
||||
|
||||
@@ -284,7 +284,7 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
||||
if params:
|
||||
system = params.pop('system', None)
|
||||
form_data = apply_model_params_to_body_openai(params, form_data)
|
||||
form_data = apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||
form_data = await apply_system_prompt_to_body(system, form_data, metadata, user)
|
||||
|
||||
pipe_id = get_pipe_id(form_data)
|
||||
function_module = await get_function_module_by_id(request, pipe_id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
@@ -332,6 +333,15 @@ get_db = contextmanager(get_session)
|
||||
# all work without any stripping or translation.
|
||||
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
# psycopg v3 cannot run in async mode under Windows' default
|
||||
# ProactorEventLoop — switch to SelectorEventLoop before creating
|
||||
# the async engine. This runs at import time, which is early enough
|
||||
# to cover every entry point (workers, reload, direct invocations).
|
||||
if sys.platform == 'win32' and _is_postgres_url(DATABASE_URL):
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
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
|
||||
|
||||
+195
-50
@@ -199,6 +199,7 @@ from open_webui.config import (
|
||||
AUDIO_STT_ENGINE,
|
||||
AUDIO_STT_MODEL,
|
||||
AUDIO_STT_SUPPORTED_CONTENT_TYPES,
|
||||
AUDIO_STT_ALLOWED_EXTENSIONS,
|
||||
AUDIO_STT_OPENAI_API_BASE_URL,
|
||||
AUDIO_STT_OPENAI_API_KEY,
|
||||
AUDIO_STT_AZURE_API_KEY,
|
||||
@@ -344,6 +345,7 @@ from open_webui.config import (
|
||||
BING_SEARCH_V7_ENDPOINT,
|
||||
BING_SEARCH_V7_SUBSCRIPTION_KEY,
|
||||
BRAVE_SEARCH_API_KEY,
|
||||
BRAVE_SEARCH_CONTEXT_TOKENS,
|
||||
EXA_API_KEY,
|
||||
PERPLEXITY_API_KEY,
|
||||
PERPLEXITY_MODEL,
|
||||
@@ -458,6 +460,7 @@ from open_webui.config import (
|
||||
OAUTH_PROVIDERS,
|
||||
WEBUI_URL,
|
||||
RESPONSE_WATERMARK,
|
||||
IFRAME_CSP,
|
||||
# Admin
|
||||
ENABLE_ADMIN_CHAT_ACCESS,
|
||||
ENABLE_ADMIN_ANALYTICS,
|
||||
@@ -478,6 +481,7 @@ from open_webui.config import (
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
VOICE_MODE_PROMPT_TEMPLATE,
|
||||
ENABLE_VOICE_MODE_PROMPT,
|
||||
QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
|
||||
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
|
||||
@@ -580,6 +584,8 @@ from open_webui.utils.redis import get_redis_connection
|
||||
from open_webui.tasks import (
|
||||
redis_task_command_listener,
|
||||
list_task_ids_by_item_id,
|
||||
has_active_tasks,
|
||||
cleanup_task,
|
||||
create_task,
|
||||
stop_task,
|
||||
stop_item_tasks,
|
||||
@@ -706,30 +712,34 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Pre-fetch tool server specs so the first request doesn't pay the latency cost
|
||||
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
|
||||
mock_request = Request(
|
||||
{
|
||||
'type': 'http',
|
||||
'asgi.version': '3.0',
|
||||
'asgi.spec_version': '2.0',
|
||||
'method': 'GET',
|
||||
'path': '/internal',
|
||||
'query_string': b'',
|
||||
'headers': Headers({}).raw,
|
||||
'client': ('127.0.0.1', 12345),
|
||||
'server': ('127.0.0.1', 80),
|
||||
'scheme': 'http',
|
||||
'app': app,
|
||||
}
|
||||
)
|
||||
|
||||
log.info('Initializing tool servers...')
|
||||
try:
|
||||
mock_request = Request(
|
||||
{
|
||||
'type': 'http',
|
||||
'asgi.version': '3.0',
|
||||
'asgi.spec_version': '2.0',
|
||||
'method': 'GET',
|
||||
'path': '/internal',
|
||||
'query_string': b'',
|
||||
'headers': Headers({}).raw,
|
||||
'client': ('127.0.0.1', 12345),
|
||||
'server': ('127.0.0.1', 80),
|
||||
'scheme': 'http',
|
||||
'app': app,
|
||||
}
|
||||
)
|
||||
await set_tool_servers(mock_request)
|
||||
log.info(f'Initialized {len(app.state.TOOL_SERVERS)} tool server(s)')
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to initialize tool servers at startup: {e}')
|
||||
|
||||
try:
|
||||
await set_terminal_servers(mock_request)
|
||||
log.info(f'Initialized {len(app.state.TERMINAL_SERVERS)} terminal server(s)')
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to initialize tool/terminal servers at startup: {e}')
|
||||
log.warning(f'Failed to initialize terminal servers at startup: {e}')
|
||||
|
||||
# Mark application as ready to accept traffic from a startup perspective.
|
||||
app.state.startup_complete = True
|
||||
@@ -1102,6 +1112,7 @@ app.state.config.YACY_PASSWORD = YACY_PASSWORD
|
||||
app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY
|
||||
app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
|
||||
app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY
|
||||
app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS = BRAVE_SEARCH_CONTEXT_TOKENS
|
||||
app.state.config.KAGI_SEARCH_API_KEY = KAGI_SEARCH_API_KEY
|
||||
app.state.config.MOJEEK_SEARCH_API_KEY = MOJEEK_SEARCH_API_KEY
|
||||
app.state.config.BOCHA_SEARCH_API_KEY = BOCHA_SEARCH_API_KEY
|
||||
@@ -1289,6 +1300,7 @@ app.state.config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES = IMAGES_EDIT_COMFYUI_WORKFL
|
||||
app.state.config.STT_ENGINE = AUDIO_STT_ENGINE
|
||||
app.state.config.STT_MODEL = AUDIO_STT_MODEL
|
||||
app.state.config.STT_SUPPORTED_CONTENT_TYPES = AUDIO_STT_SUPPORTED_CONTENT_TYPES
|
||||
app.state.config.STT_ALLOWED_EXTENSIONS = AUDIO_STT_ALLOWED_EXTENSIONS
|
||||
|
||||
app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL
|
||||
app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY
|
||||
@@ -1361,6 +1373,7 @@ app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = QUERY_GENERATION_PROMPT_TEMP
|
||||
app.state.config.AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE = AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
|
||||
app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH
|
||||
app.state.config.VOICE_MODE_PROMPT_TEMPLATE = VOICE_MODE_PROMPT_TEMPLATE
|
||||
app.state.config.ENABLE_VOICE_MODE_PROMPT = ENABLE_VOICE_MODE_PROMPT
|
||||
|
||||
|
||||
########################################
|
||||
@@ -1524,6 +1537,108 @@ async def get_base_models(request: Request, user=Depends(get_admin_user)):
|
||||
return {'data': models}
|
||||
|
||||
|
||||
class ModelUnloadForm(BaseModel):
|
||||
model: str
|
||||
|
||||
|
||||
@app.post('/api/models/unload')
|
||||
async def unload_model(request: Request, form_data: ModelUnloadForm, user=Depends(get_admin_user)):
|
||||
"""
|
||||
Unified model unload endpoint.
|
||||
Resolves the provider that owns the model and calls its native unload mechanism.
|
||||
Supports: Ollama (keep_alive=0) and llama.cpp (/models/unload).
|
||||
"""
|
||||
model_id = form_data.model
|
||||
|
||||
# --- Ollama provider ---
|
||||
ollama_models = getattr(request.app.state, 'OLLAMA_MODELS', None) or {}
|
||||
if model_id in ollama_models:
|
||||
url_indices = ollama_models[model_id].get('urls', [])
|
||||
errors = []
|
||||
for idx in url_indices:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}),
|
||||
)
|
||||
key = api_config.get('key', None)
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
actual_model = model_id
|
||||
if prefix_id and actual_model.startswith(f'{prefix_id}.'):
|
||||
actual_model = actual_model[len(f'{prefix_id}.') :]
|
||||
|
||||
payload = json.dumps({'model': actual_model, 'keep_alive': 0, 'prompt': ''})
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
**({'Authorization': f'Bearer {key}'} if key else {}),
|
||||
}
|
||||
async with session.post(
|
||||
f'{url}/api/generate',
|
||||
data=payload,
|
||||
headers=headers,
|
||||
) as r:
|
||||
if not r.ok:
|
||||
errors.append({'url_idx': idx, 'error': await r.text()})
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to unload model on Ollama node {idx}: {e}')
|
||||
errors.append({'url_idx': idx, 'error': str(e)})
|
||||
|
||||
if errors:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f'Failed to unload model on {len(errors)} node(s): {errors}',
|
||||
)
|
||||
return {'status': True}
|
||||
|
||||
# --- OpenAI-compatible providers ---
|
||||
openai_models = getattr(request.app.state, 'OPENAI_MODELS', None) or {}
|
||||
if model_id in openai_models:
|
||||
model_info = openai_models[model_id]
|
||||
idx = model_info.get('urlIdx')
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(str(idx), {})
|
||||
provider = api_config.get('provider', '')
|
||||
base_url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
key = (
|
||||
request.app.state.config.OPENAI_API_KEYS[idx] if idx < len(request.app.state.config.OPENAI_API_KEYS) else ''
|
||||
)
|
||||
|
||||
if provider == 'llama.cpp':
|
||||
root_url = base_url.rstrip('/').removesuffix('/v1')
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
**({'Authorization': f'Bearer {key}'} if key else {}),
|
||||
}
|
||||
async with session.post(
|
||||
f'{root_url}/models/unload',
|
||||
json={'model': model_id},
|
||||
headers=headers,
|
||||
) as r:
|
||||
if not r.ok:
|
||||
detail = await r.text()
|
||||
raise HTTPException(status_code=r.status, detail=detail)
|
||||
return await r.json()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to unload model via llama.cpp: {e}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Provider "{provider or "default"}" does not support model unloading',
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=404, detail=f'Model "{model_id}" not found')
|
||||
|
||||
|
||||
##################################
|
||||
# Embeddings
|
||||
##################################
|
||||
@@ -1651,6 +1766,7 @@ async def chat_completion(
|
||||
'chat_id': form_data.pop('chat_id', None),
|
||||
'user_message': user_message,
|
||||
'user_message_id': user_message.get('id') if user_message else None,
|
||||
'assistant_message_id': form_data.pop('assistant_message_id', None),
|
||||
'session_id': form_data.pop('session_id', None),
|
||||
'folder_id': form_data.pop('folder_id', None),
|
||||
'filter_ids': form_data.pop('filter_ids', []),
|
||||
@@ -1680,7 +1796,9 @@ async def chat_completion(
|
||||
|
||||
if metadata.get('chat_id') and user:
|
||||
chat_id = metadata['chat_id']
|
||||
if not chat_id.startswith('local:'): # temporary chats are not stored
|
||||
if not chat_id.startswith('local:') and not chat_id.startswith(
|
||||
'channel:'
|
||||
): # temporary/channel chats are not stored
|
||||
if is_new_chat:
|
||||
# Build the full history upfront with ALL assistant placeholders
|
||||
user_message = metadata.get('user_message') or {}
|
||||
@@ -1723,6 +1841,7 @@ async def chat_completion(
|
||||
]
|
||||
if user_message_id
|
||||
else [],
|
||||
'files': metadata.get('files') or [],
|
||||
'tags': [],
|
||||
'timestamp': int(time.time() * 1000),
|
||||
},
|
||||
@@ -1755,6 +1874,16 @@ async def chat_completion(
|
||||
detail=ERROR_MESSAGES.DEFAULT(),
|
||||
)
|
||||
|
||||
# Persist chat-level files (knowledge collections, docs, etc.)
|
||||
# The old frontend saveChatHandler did this on every message;
|
||||
# now the backend owns persistence.
|
||||
chat_files = metadata.get('files')
|
||||
if chat_files is not None:
|
||||
existing_chat = await Chats.get_chat_by_id(chat_id)
|
||||
if existing_chat:
|
||||
updated = {**existing_chat.chat, 'files': chat_files}
|
||||
await Chats.update_chat_by_id(chat_id, updated)
|
||||
|
||||
# Save user message to DB
|
||||
user_message = metadata.get('user_message') or {}
|
||||
if user_message and user_message.get('id'):
|
||||
@@ -1885,7 +2014,7 @@ async def chat_completion(
|
||||
if metadata.get('chat_id') and metadata.get('message_id'):
|
||||
# Update the chat message with the error
|
||||
try:
|
||||
if not metadata['chat_id'].startswith('local:'):
|
||||
if not metadata['chat_id'].startswith('local:') and not metadata['chat_id'].startswith('channel:'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
@@ -1919,42 +2048,42 @@ async def chat_completion(
|
||||
detail=error_detail,
|
||||
)
|
||||
finally:
|
||||
# MCP cleanup — MUST run in the SAME asyncio task as
|
||||
# connect() because the MCP SDK's streamablehttp_client
|
||||
# uses anyio task groups whose cancel scopes enforce
|
||||
# same-task exit. Do NOT wrap in asyncio.shield() or
|
||||
# asyncio.wait_for() — both create a new task.
|
||||
# Clean up MCP clients. Each client is isolated so one
|
||||
# failure doesn't skip the rest.
|
||||
#
|
||||
# NOTE: asyncio.wait_for() / asyncio.shield() must NOT be used
|
||||
# here — they create new asyncio Tasks, which violate anyio
|
||||
# cancel-scope task-ownership rules when the MCPClient's
|
||||
# exit_stack contains anyio transport resources (streamable_http).
|
||||
# Exiting those cancel scopes from the wrong task raises
|
||||
# "Attempted to exit a cancel scope that isn't the current
|
||||
# task's current cancel scope", which propagates as a
|
||||
# BaseException through the finally block, discards the response
|
||||
# return value, and surfaces as a 500 "No response returned."
|
||||
# MCPClient.disconnect() already catches BaseException internally.
|
||||
try:
|
||||
if mcp_clients := metadata.get('mcp_clients'):
|
||||
for client in reversed(list(mcp_clients.values())):
|
||||
try:
|
||||
await client.disconnect()
|
||||
except Exception as e:
|
||||
except BaseException as e:
|
||||
log.debug(f'Error disconnecting MCP client: {e}')
|
||||
except asyncio.CancelledError:
|
||||
# Let the client close asynchronously by GC
|
||||
pass
|
||||
except Exception as e:
|
||||
except BaseException as e:
|
||||
log.debug(f'Error cleaning up MCP clients: {e}')
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Deregister this task, then emit chat:active=false if no others remain
|
||||
try:
|
||||
if metadata.get('chat_id'):
|
||||
|
||||
async def emit_inactive_event():
|
||||
try:
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:active', 'data': {'active': False}})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Shield the event emission so it finishes even if the main task is cancelled
|
||||
await asyncio.shield(emit_inactive_event())
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
chat_id = metadata.get('chat_id')
|
||||
task_id = metadata.get('task_id')
|
||||
if chat_id and task_id:
|
||||
await cleanup_task(request.app.state.redis, task_id, chat_id)
|
||||
if not await has_active_tasks(request.app.state.redis, chat_id):
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
try:
|
||||
await asyncio.shield(event_emitter({'type': 'chat:active', 'data': {'active': False}}))
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -2004,6 +2133,7 @@ async def chat_completion(
|
||||
),
|
||||
id=chat_id,
|
||||
)
|
||||
per_model_metadata['task_id'] = task_id
|
||||
task_ids.append(task_id)
|
||||
|
||||
# Emit chat:active=true
|
||||
@@ -2147,7 +2277,7 @@ async def list_tasks_endpoint(request: Request, user=Depends(get_admin_user)):
|
||||
|
||||
@app.get('/api/tasks/chat/{chat_id:path}')
|
||||
async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
|
||||
if chat_id.startswith('local:'):
|
||||
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
|
||||
socket_id = chat_id[len('local:') :]
|
||||
owner_id = get_user_id_from_session_pool(socket_id)
|
||||
if owner_id != user.id and user.role != 'admin':
|
||||
@@ -2165,7 +2295,7 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
|
||||
|
||||
@app.post('/api/tasks/chat/{chat_id:path}/stop')
|
||||
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
|
||||
if chat_id.startswith('local:'):
|
||||
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
|
||||
socket_id = chat_id[len('local:') :]
|
||||
owner_id = get_user_id_from_session_pool(socket_id)
|
||||
if owner_id != user.id and user.role != 'admin':
|
||||
@@ -2317,6 +2447,7 @@ async def get_app_config(request: Request):
|
||||
'pending_user_overlay_title': app.state.config.PENDING_USER_OVERLAY_TITLE,
|
||||
'pending_user_overlay_content': app.state.config.PENDING_USER_OVERLAY_CONTENT,
|
||||
'response_watermark': app.state.config.RESPONSE_WATERMARK,
|
||||
'iframe_csp': IFRAME_CSP,
|
||||
},
|
||||
'license_metadata': app.state.LICENSE_METADATA,
|
||||
**(
|
||||
@@ -2710,6 +2841,14 @@ async def get_opensearch_xml():
|
||||
return Response(content=xml_content, media_type='application/xml')
|
||||
|
||||
|
||||
def _sync_db_ping() -> None:
|
||||
ScopedSession.execute(text('SELECT 1;')).all()
|
||||
|
||||
|
||||
async def async_db_ping() -> None:
|
||||
await asyncio.to_thread(_sync_db_ping)
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
async def healthcheck():
|
||||
return {'status': True}
|
||||
@@ -2731,7 +2870,7 @@ async def readiness_check():
|
||||
|
||||
# Check database connectivity
|
||||
try:
|
||||
ScopedSession.execute(text('SELECT 1;')).all()
|
||||
await async_db_ping()
|
||||
except Exception as e:
|
||||
log.warning(f'Readiness check DB ping failed: {e!r}')
|
||||
raise HTTPException(
|
||||
@@ -2758,7 +2897,7 @@ async def readiness_check():
|
||||
|
||||
@app.get('/health/db')
|
||||
async def healthcheck_with_db():
|
||||
ScopedSession.execute(text('SELECT 1;')).all()
|
||||
await async_db_ping()
|
||||
return {'status': True}
|
||||
|
||||
|
||||
@@ -2776,7 +2915,13 @@ async def serve_cache_file(
|
||||
raise HTTPException(status_code=404, detail='File not found')
|
||||
if not os.path.isfile(file_path):
|
||||
raise HTTPException(status_code=404, detail='File not found')
|
||||
return FileResponse(file_path)
|
||||
|
||||
mime, _ = mimetypes.guess_type(file_path)
|
||||
inline_safe = mime and mime.split('/', 1)[0] in {'image', 'audio', 'video'}
|
||||
headers = {'X-Content-Type-Options': 'nosniff'}
|
||||
if not inline_safe:
|
||||
headers['Content-Disposition'] = f'attachment; filename="{os.path.basename(file_path)}"'
|
||||
return FileResponse(file_path, headers=headers)
|
||||
|
||||
|
||||
def swagger_ui_html(*args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""add pinned_note table
|
||||
|
||||
Revision ID: 4de81c2a3af1
|
||||
Revises: 56359461a091
|
||||
Create Date: 2026-05-09 04:29:27.651341
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import open_webui.internal.db
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4de81c2a3af1'
|
||||
down_revision: Union[str, None] = '56359461a091'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
import uuid
|
||||
import time
|
||||
from sqlalchemy import select, update, insert
|
||||
from sqlalchemy.sql import table, column
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'pinned_note',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('user_id', sa.Text(), nullable=False),
|
||||
sa.Column('note_id', sa.Text(), sa.ForeignKey('note.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('created_at', sa.BigInteger(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'note_id', name='uq_pinned_note'),
|
||||
)
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
note_table = table('note', column('id', sa.Text), column('user_id', sa.Text), column('is_pinned', sa.Boolean))
|
||||
|
||||
pinned_note_table = table(
|
||||
'pinned_note',
|
||||
column('id', sa.Text),
|
||||
column('user_id', sa.Text),
|
||||
column('note_id', sa.Text),
|
||||
column('created_at', sa.BigInteger),
|
||||
)
|
||||
|
||||
notes = conn.execute(select(note_table.c.id, note_table.c.user_id).where(note_table.c.is_pinned == True)).fetchall()
|
||||
|
||||
if notes:
|
||||
now = int(time.time_ns())
|
||||
conn.execute(
|
||||
insert(pinned_note_table),
|
||||
[{'id': str(uuid.uuid4()), 'user_id': note[1], 'note_id': note[0], 'created_at': now} for note in notes],
|
||||
)
|
||||
|
||||
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_pinned')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('note', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('is_pinned', sa.Boolean(), nullable=True))
|
||||
|
||||
conn = op.get_bind()
|
||||
|
||||
note_table = table('note', column('id', sa.Text), column('is_pinned', sa.Boolean))
|
||||
|
||||
pinned_note_table = table('pinned_note', column('note_id', sa.Text))
|
||||
|
||||
notes = conn.execute(select(pinned_note_table.c.note_id)).fetchall()
|
||||
|
||||
for note in notes:
|
||||
conn.execute(update(note_table).where(note_table.c.id == note[0]).values(is_pinned=True))
|
||||
|
||||
op.drop_table('pinned_note')
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Add memory user_id index
|
||||
|
||||
Revision ID: a0b1c2d3e4f5
|
||||
Revises: 4de81c2a3af1
|
||||
Create Date: 2025-09-15 03:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = 'a0b1c2d3e4f5'
|
||||
down_revision = '4de81c2a3af1'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_index('ix_memory_user_id', 'memory', ['user_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_memory_user_id', table_name='memory')
|
||||
@@ -395,14 +395,16 @@ class CalendarTable:
|
||||
# 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:
|
||||
|
||||
# Revoke access grants in a separate transaction to avoid
|
||||
# write-lock contention on SQLite when session sharing is off.
|
||||
await AccessGrants.revoke_all_access('calendar', id)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.exception(f'Failed to delete calendar {id}: {e}')
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.utils.validate import validate_profile_image_url
|
||||
|
||||
from sqlalchemy import select, delete, update, func, case, or_, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
@@ -13,7 +15,7 @@ from open_webui.models.access_grants import (
|
||||
AccessGrants,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
@@ -244,6 +246,13 @@ class ChannelWebhookForm(BaseModel):
|
||||
name: str
|
||||
profile_image_url: Optional[str] = None
|
||||
|
||||
@field_validator('profile_image_url', mode='before')
|
||||
@classmethod
|
||||
def check_profile_image_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
return validate_profile_image_url(v)
|
||||
|
||||
|
||||
class ChannelTable:
|
||||
async def _get_access_grants(self, channel_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
|
||||
|
||||
@@ -48,6 +48,25 @@ def get_usage(data: dict) -> Optional[dict]:
|
||||
return normalize_usage(usage) if usage else None
|
||||
|
||||
|
||||
def _token_columns(dialect: str):
|
||||
"""Return (input_tokens, output_tokens) SQL column expressions.
|
||||
|
||||
Falls back to OpenAI-style keys (prompt_tokens / completion_tokens)
|
||||
when the normalized keys are absent.
|
||||
"""
|
||||
if dialect == 'sqlite':
|
||||
extract = lambda key: cast(func.json_extract(ChatMessage.usage, f'$.{key}'), Integer)
|
||||
elif dialect == 'postgresql':
|
||||
extract = lambda key: cast(func.json_extract_path_text(ChatMessage.usage, key), Integer)
|
||||
else:
|
||||
raise NotImplementedError(f'Unsupported dialect: {dialect}')
|
||||
|
||||
return (
|
||||
func.coalesce(extract('input_tokens'), extract('prompt_tokens')),
|
||||
func.coalesce(extract('output_tokens'), extract('completion_tokens')),
|
||||
)
|
||||
|
||||
|
||||
####################
|
||||
# ChatMessage DB Schema
|
||||
####################
|
||||
@@ -222,6 +241,79 @@ class ChatMessageTable:
|
||||
messages = result.scalars().all()
|
||||
return [ChatMessageModel.model_validate(message) for message in messages]
|
||||
|
||||
# DB column names that differ from the JSON message keys.
|
||||
DB_TO_JSON_KEY_MAP = {
|
||||
'parent_id': 'parentId',
|
||||
'model_id': 'model',
|
||||
'status_history': 'statusHistory',
|
||||
'created_at': 'timestamp',
|
||||
}
|
||||
# DB-internal columns excluded from the reconstructed message dict.
|
||||
EXCLUDED_COLUMNS = frozenset({'id', 'chat_id', 'user_id', 'updated_at'})
|
||||
|
||||
async def get_messages_map_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> Optional[dict]:
|
||||
"""Build a {message_id: message_dict} map from chat_message rows.
|
||||
|
||||
Returns the same shape as chat.history.messages so callers
|
||||
(get_message_list, middleware) work unchanged. Returns None if
|
||||
no rows exist for the chat (caller should fall back to the
|
||||
embedded JSON blob for legacy chats).
|
||||
"""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(ChatMessage).filter_by(chat_id=chat_id))
|
||||
rows = result.scalars().all()
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
# Strip the composite-id prefix ("{chat_id}-") to recover the
|
||||
# original message_id used as map key.
|
||||
prefix = f'{chat_id}-'
|
||||
prefix_len = len(prefix)
|
||||
col_keys = [c.key for c in ChatMessage.__table__.columns]
|
||||
|
||||
messages_map: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
msg_id = row.id[prefix_len:] if row.id.startswith(prefix) else row.id
|
||||
|
||||
msg: dict = {'id': msg_id}
|
||||
for key in col_keys:
|
||||
if key in self.EXCLUDED_COLUMNS:
|
||||
continue
|
||||
val = getattr(row, key)
|
||||
if val is None:
|
||||
continue
|
||||
json_key = self.DB_TO_JSON_KEY_MAP.get(key, key)
|
||||
msg[json_key] = val
|
||||
|
||||
# Ensure content always has a value
|
||||
msg.setdefault('content', '')
|
||||
|
||||
# Mirror usage into info.usage for callers that read it there
|
||||
if 'usage' in msg:
|
||||
msg['info'] = {'usage': msg['usage']}
|
||||
|
||||
messages_map[msg_id] = msg
|
||||
|
||||
# Reconstruct childrenIds from parentId links so that the map
|
||||
# is fully navigable (callers like the frontend rely on this).
|
||||
for msg_id, msg in messages_map.items():
|
||||
parent_id = msg.get('parentId')
|
||||
if parent_id and parent_id in messages_map:
|
||||
parent = messages_map[parent_id]
|
||||
children = parent.get('childrenIds')
|
||||
if children is None:
|
||||
parent['childrenIds'] = [msg_id]
|
||||
elif msg_id not in children:
|
||||
children.append(msg_id)
|
||||
|
||||
# Ensure every message has a childrenIds list (leaf nodes get [])
|
||||
for msg in messages_map.values():
|
||||
if 'childrenIds' not in msg:
|
||||
msg['childrenIds'] = []
|
||||
|
||||
return messages_map
|
||||
|
||||
async def get_messages_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -343,20 +435,7 @@ class ChatMessageTable:
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
if dialect == 'sqlite':
|
||||
input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer)
|
||||
output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer)
|
||||
elif dialect == 'postgresql':
|
||||
input_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, 'input_tokens'),
|
||||
Integer,
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, 'output_tokens'),
|
||||
Integer,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f'Unsupported dialect: {dialect}')
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
stmt = select(
|
||||
ChatMessage.model_id,
|
||||
@@ -404,20 +483,7 @@ class ChatMessageTable:
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
|
||||
if dialect == 'sqlite':
|
||||
input_tokens = cast(func.json_extract(ChatMessage.usage, '$.input_tokens'), Integer)
|
||||
output_tokens = cast(func.json_extract(ChatMessage.usage, '$.output_tokens'), Integer)
|
||||
elif dialect == 'postgresql':
|
||||
input_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, 'input_tokens'),
|
||||
Integer,
|
||||
)
|
||||
output_tokens = cast(
|
||||
func.json_extract_path_text(ChatMessage.usage, 'output_tokens'),
|
||||
Integer,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f'Unsupported dialect: {dialect}')
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
stmt = select(
|
||||
ChatMessage.user_id,
|
||||
|
||||
@@ -366,20 +366,20 @@ class ChatTable:
|
||||
await db.commit()
|
||||
|
||||
# Dual-write messages to chat_message table
|
||||
try:
|
||||
for form_data, chat_obj in zip(chat_import_forms, chats):
|
||||
history = form_data.chat.get('history', {})
|
||||
messages = history.get('messages', {})
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get('role'):
|
||||
for form_data, chat_obj in zip(chat_import_forms, chats):
|
||||
history = form_data.chat.get('history', {})
|
||||
messages = history.get('messages', {})
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get('role'):
|
||||
try:
|
||||
await ChatMessages.upsert_message(
|
||||
message_id=message_id,
|
||||
chat_id=chat_obj.id,
|
||||
user_id=user_id,
|
||||
data=message,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to write imported messages to chat_message table: {e}')
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to write imported message {message_id} for chat {chat_obj.id}: {e}')
|
||||
|
||||
return [ChatModel.model_validate(chat) for chat in chats]
|
||||
|
||||
@@ -459,12 +459,87 @@ class ChatTable:
|
||||
return None
|
||||
return row[0] or 'New Chat'
|
||||
|
||||
@staticmethod
|
||||
def get_unresolved_parent_ids(messages_map: dict) -> set[str]:
|
||||
"""Return parent IDs referenced by messages but absent from the map.
|
||||
|
||||
An empty set means the message graph is fully connected.
|
||||
"""
|
||||
return {
|
||||
msg['parentId']
|
||||
for msg in messages_map.values()
|
||||
if msg.get('parentId') and msg['parentId'] not in messages_map
|
||||
}
|
||||
|
||||
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
|
||||
"""Write messages to the ``chat_message`` table so future lookups
|
||||
use the fast path. Errors are logged but never raised.
|
||||
"""
|
||||
for message_id, message in messages.items():
|
||||
if not isinstance(message, dict) or not message.get('role'):
|
||||
continue
|
||||
try:
|
||||
await ChatMessages.upsert_message(
|
||||
message_id=message_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
data=message,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
|
||||
|
||||
async def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
|
||||
"""Message map for walking history (see ``get_message_list``).
|
||||
|
||||
Prefer ``chat_message`` rows to avoid loading the large embedded
|
||||
history; fall back to the legacy JSON when no rows exist.
|
||||
When rows exist but the parent-link graph has gaps (e.g. migration
|
||||
failures), missing messages are merged from the legacy history
|
||||
and backfilled so future requests self-heal.
|
||||
"""
|
||||
# Fast path: build from normalized chat_message rows.
|
||||
messages_map = await ChatMessages.get_messages_map_by_chat_id(id)
|
||||
|
||||
if messages_map is not None:
|
||||
unresolved_ids = self.get_unresolved_parent_ids(messages_map)
|
||||
if not unresolved_ids:
|
||||
return messages_map
|
||||
|
||||
# Graph has gaps — enrich from the legacy embedded history.
|
||||
log.info(
|
||||
'Chat %s: %d unresolved parent reference(s) in chat_message — enriching from legacy history',
|
||||
id,
|
||||
len(unresolved_ids),
|
||||
)
|
||||
chat = await self.get_chat_by_id(id)
|
||||
if chat:
|
||||
history_messages = chat.chat.get('history', {}).get('messages', {}) or {}
|
||||
missing_messages = {
|
||||
message_id: history_messages[message_id]
|
||||
for message_id in unresolved_ids
|
||||
if message_id in history_messages
|
||||
}
|
||||
|
||||
if missing_messages:
|
||||
messages_map.update(missing_messages)
|
||||
|
||||
# Backfill so future requests use the fast path.
|
||||
await self.backfill_messages_by_chat_id(id, chat.user_id, missing_messages)
|
||||
|
||||
return messages_map
|
||||
|
||||
# No rows — fall back to the legacy embedded history.
|
||||
chat = await self.get_chat_by_id(id)
|
||||
if chat is None:
|
||||
return None
|
||||
|
||||
return chat.chat.get('history', {}).get('messages', {}) or {}
|
||||
history_messages = chat.chat.get('history', {}).get('messages', {}) or {}
|
||||
|
||||
# Backfill so future requests use the fast path.
|
||||
if history_messages:
|
||||
await self.backfill_messages_by_chat_id(id, chat.user_id, history_messages)
|
||||
|
||||
return history_messages
|
||||
|
||||
async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> Optional[dict]:
|
||||
chat = await self.get_chat_by_id(id)
|
||||
|
||||
@@ -103,7 +103,8 @@ class FeedbackForm(BaseModel):
|
||||
data: Optional[RatingData] = None
|
||||
meta: Optional[dict] = None
|
||||
snapshot: Optional[SnapshotData] = None
|
||||
model_config = ConfigDict(extra='allow')
|
||||
# ignore: drop client-supplied id/user_id/version/timestamps at parse time.
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
@@ -145,12 +146,13 @@ class FeedbackTable:
|
||||
) -> Optional[FeedbackModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
id = str(uuid.uuid4())
|
||||
# Spread form_data first so server-controlled fields win on duplicate keys.
|
||||
feedback = FeedbackModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
'id': id,
|
||||
'user_id': user_id,
|
||||
'version': 0,
|
||||
**form_data.model_dump(),
|
||||
'created_at': int(time.time()),
|
||||
'updated_at': int(time.time()),
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import time
|
||||
from typing import Optional
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select, delete, update, or_, func
|
||||
from sqlalchemy import select, delete, update, or_, func, cast
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
|
||||
@@ -313,11 +313,16 @@ class KnowledgeTable:
|
||||
permission='read',
|
||||
)
|
||||
|
||||
# Apply filename search
|
||||
# Apply filename / content search
|
||||
if filter:
|
||||
q = filter.get('query')
|
||||
if q:
|
||||
stmt = stmt.filter(File.filename.ilike(f'%{q}%'))
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
File.filename.ilike(f'%{q}%'),
|
||||
cast(File.data['content'], Text).ilike(f'%{q}%'),
|
||||
)
|
||||
)
|
||||
|
||||
# Order by file changes
|
||||
stmt = stmt.order_by(File.updated_at.desc(), File.id.asc())
|
||||
@@ -467,7 +472,12 @@ class KnowledgeTable:
|
||||
if filter:
|
||||
query_key = filter.get('query')
|
||||
if query_key:
|
||||
stmt = stmt.filter(or_(File.filename.ilike(f'%{query_key}%')))
|
||||
stmt = stmt.filter(
|
||||
or_(
|
||||
File.filename.ilike(f'%{query_key}%'),
|
||||
cast(File.data['content'], Text).ilike(f'%{query_key}%'),
|
||||
)
|
||||
)
|
||||
|
||||
view_option = filter.get('view_option')
|
||||
if view_option == 'created':
|
||||
|
||||
@@ -19,7 +19,7 @@ class Memory(Base):
|
||||
__tablename__ = 'memory'
|
||||
|
||||
id = Column(String, primary_key=True, unique=True)
|
||||
user_id = Column(String)
|
||||
user_id = Column(String, index=True)
|
||||
content = Column(Text)
|
||||
updated_at = Column(BigInteger)
|
||||
created_at = Column(BigInteger)
|
||||
|
||||
@@ -13,7 +13,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON
|
||||
from sqlalchemy import BigInteger, Column, Text, JSON, ForeignKey
|
||||
|
||||
####################
|
||||
# Note DB Schema
|
||||
@@ -29,7 +29,6 @@ class Note(Base):
|
||||
title = Column(Text)
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
is_pinned = Column(Boolean, default=False, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
@@ -52,6 +51,15 @@ class NoteModel(BaseModel):
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
|
||||
class PinnedNote(Base):
|
||||
__tablename__ = 'pinned_note'
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text, nullable=False)
|
||||
note_id = Column(Text, ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
|
||||
created_at = Column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
@@ -100,6 +108,7 @@ class NoteTable:
|
||||
access_grants: Optional[list[AccessGrantModel]] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> NoteModel:
|
||||
# We exclude access_grants to inject them
|
||||
note_data = NoteModel.model_validate(note).model_dump(exclude={'access_grants'})
|
||||
note_data['access_grants'] = (
|
||||
access_grants if access_grants is not None else await self._get_access_grants(note_data['id'], db=db)
|
||||
@@ -131,7 +140,7 @@ class NoteTable:
|
||||
}
|
||||
)
|
||||
|
||||
new_note = Note(**note.model_dump(exclude={'access_grants'}))
|
||||
new_note = Note(**note.model_dump(exclude={'access_grants', 'is_pinned'}))
|
||||
|
||||
db.add(new_note)
|
||||
await db.commit()
|
||||
@@ -314,15 +323,28 @@ class NoteTable:
|
||||
await db.commit()
|
||||
return await self._to_note_model(note, db=db) if note else None
|
||||
|
||||
async def toggle_note_pinned_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]:
|
||||
async def toggle_note_pinned_by_id(
|
||||
self, id: str, user_id: str, db: Optional[AsyncSession] = None
|
||||
) -> Optional[NoteModel]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Note).filter(Note.id == id))
|
||||
note = result.scalars().first()
|
||||
if not note:
|
||||
return None
|
||||
note.is_pinned = not note.is_pinned
|
||||
note.updated_at = int(time.time_ns())
|
||||
|
||||
# Check if already pinned
|
||||
pin_result = await db.execute(select(PinnedNote).filter_by(user_id=user_id, note_id=id))
|
||||
pinned_note = pin_result.scalars().first()
|
||||
|
||||
if pinned_note:
|
||||
await db.execute(delete(PinnedNote).filter_by(user_id=user_id, note_id=id))
|
||||
else:
|
||||
new_pin = PinnedNote(
|
||||
id=str(uuid.uuid4()), user_id=user_id, note_id=id, created_at=int(time.time_ns())
|
||||
)
|
||||
db.add(new_pin)
|
||||
|
||||
await db.commit()
|
||||
return await self._to_note_model(note, db=db)
|
||||
except Exception:
|
||||
@@ -338,7 +360,12 @@ class NoteTable:
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = [group.id for group in user_groups]
|
||||
|
||||
stmt = select(Note).filter(Note.is_pinned == True).order_by(Note.updated_at.desc())
|
||||
stmt = (
|
||||
select(Note)
|
||||
.join(PinnedNote, PinnedNote.note_id == Note.id)
|
||||
.filter(PinnedNote.user_id == user_id)
|
||||
.order_by(PinnedNote.created_at.desc())
|
||||
)
|
||||
stmt = self._has_permission(db, stmt, {'user_id': user_id, 'group_ids': user_group_ids}, permission)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
@@ -351,11 +378,17 @@ class NoteTable:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
await AccessGrants.revoke_all_access('note', id, db=db)
|
||||
await db.execute(delete(PinnedNote).filter(PinnedNote.note_id == id))
|
||||
await db.execute(delete(Note).filter(Note.id == id))
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_pinned_note_ids(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(PinnedNote.note_id).filter_by(user_id=user_id))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
Notes = NoteTable()
|
||||
|
||||
@@ -231,24 +231,51 @@ class PromptsTable:
|
||||
async def get_prompts_by_user_id(
|
||||
self, user_id: str, permission: str = 'write', db: Optional[AsyncSession] = None
|
||||
) -> list[PromptUserResponse]:
|
||||
prompts = await self.get_prompts(db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
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 = [group.id for group in user_groups]
|
||||
|
||||
result = []
|
||||
for prompt in prompts:
|
||||
if prompt.user_id == user_id:
|
||||
result.append(prompt)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='prompt',
|
||||
resource_id=prompt.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
query = select(Prompt).filter(Prompt.is_active == True).order_by(Prompt.updated_at.desc())
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
):
|
||||
result.append(prompt)
|
||||
return result
|
||||
query=query,
|
||||
DocumentModel=Prompt,
|
||||
filter={'user_id': user_id, 'group_ids': user_group_ids},
|
||||
resource_type='prompt',
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
accessible_prompts = result.scalars().all()
|
||||
|
||||
if not accessible_prompts:
|
||||
return []
|
||||
|
||||
prompt_ids = [p.id for p in accessible_prompts]
|
||||
owner_ids = list({p.user_id for p in accessible_prompts})
|
||||
|
||||
users = await Users.get_users_by_user_ids(owner_ids, db=db)
|
||||
users_dict = {u.id: u for u in users}
|
||||
grants_map = await AccessGrants.get_grants_by_resources('prompt', prompt_ids, db=db)
|
||||
|
||||
results = []
|
||||
for prompt in accessible_prompts:
|
||||
user = users_dict.get(prompt.user_id)
|
||||
results.append(
|
||||
PromptUserResponse.model_validate(
|
||||
{
|
||||
**(
|
||||
await self._to_prompt_model(
|
||||
prompt,
|
||||
access_grants=grants_map.get(prompt.id, []),
|
||||
db=db,
|
||||
)
|
||||
).model_dump(),
|
||||
'user': user.model_dump() if user else None,
|
||||
}
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
async def search_prompts(
|
||||
self,
|
||||
@@ -632,12 +659,38 @@ class PromptsTable:
|
||||
async def get_tags(self, db: Optional[AsyncSession] = None) -> list[str]:
|
||||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Prompt).filter_by(is_active=True))
|
||||
prompts = result.scalars().all()
|
||||
result = await db.execute(select(Prompt.tags).filter(Prompt.is_active == True))
|
||||
tags = set()
|
||||
for prompt in prompts:
|
||||
if prompt.tags:
|
||||
for tag in prompt.tags:
|
||||
for (tag_list,) in result.all():
|
||||
if tag_list:
|
||||
for tag in tag_list:
|
||||
if tag:
|
||||
tags.add(tag)
|
||||
return sorted(list(tags))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def get_tags_by_user_id(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
|
||||
try:
|
||||
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 = [group.id for group in user_groups]
|
||||
|
||||
query = select(Prompt.tags).filter(Prompt.is_active == True)
|
||||
query = AccessGrants.has_permission_filter(
|
||||
db=db,
|
||||
query=query,
|
||||
DocumentModel=Prompt,
|
||||
filter={'user_id': user_id, 'group_ids': user_group_ids},
|
||||
resource_type='prompt',
|
||||
permission='read',
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
tags = set()
|
||||
for (tag_list,) in result.all():
|
||||
if tag_list:
|
||||
for tag in tag_list:
|
||||
if tag:
|
||||
tags.add(tag)
|
||||
return sorted(list(tags))
|
||||
|
||||
@@ -43,6 +43,7 @@ from open_webui.retrieval.loaders.youtube import YoutubeLoader
|
||||
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
OFFLINE_MODE,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
@@ -180,8 +181,12 @@ def get_content_from_url(request, url: str) -> str:
|
||||
validate_url(url)
|
||||
|
||||
# Streamed GET to check Content-Type without downloading the body.
|
||||
# allow_redirects=False prevents redirect-based SSRF: validate_url() above is
|
||||
# called on the originally-submitted URL only; following 3xx redirects without
|
||||
# re-validation would let an attacker reach private IPs (RFC1918, loopback,
|
||||
# cloud-metadata 169.254.169.254) via a public host that redirects internally.
|
||||
try:
|
||||
response = requests.get(url, stream=True, timeout=30)
|
||||
response = requests.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def search_brave_llm_context(
|
||||
api_key: str,
|
||||
query: str,
|
||||
count: int,
|
||||
filter_list: Optional[list[str]] = None,
|
||||
context_tokens: int = 8192,
|
||||
) -> list[SearchResult]:
|
||||
"""Search using Brave's LLM Context API and return pre-extracted, relevance-scored
|
||||
page content ready for LLM consumption.
|
||||
|
||||
Uses /res/v1/llm/context instead of /res/v1/web/search. Same API key, same pricing.
|
||||
Returns full extracted passages per URL rather than short snippets, eliminating
|
||||
the need for post-search scraping.
|
||||
|
||||
Args:
|
||||
api_key (str): A Brave Search API key (same key as web search)
|
||||
query (str): The query to search for
|
||||
count (int): Maximum number of results to return
|
||||
filter_list (list[str], optional): Domain filter list
|
||||
context_tokens (int): Maximum total tokens to retrieve (1024–32768, default 8192)
|
||||
"""
|
||||
url = 'https://api.search.brave.com/res/v1/llm/context'
|
||||
headers = {
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'gzip',
|
||||
'X-Subscription-Token': api_key,
|
||||
}
|
||||
params = {
|
||||
'q': query,
|
||||
'count': count,
|
||||
'maximum_number_of_tokens': context_tokens,
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
|
||||
# Handle 429 rate limiting - same rate limits as web search
|
||||
if response.status_code == 429:
|
||||
log.info('Brave LLM Context API rate limited (429), retrying after 1 second...')
|
||||
time.sleep(1)
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
json_response = response.json()
|
||||
results = json_response.get('grounding', {}).get('generic', [])
|
||||
if filter_list:
|
||||
results = get_filtered_results(results, filter_list)
|
||||
|
||||
return [
|
||||
SearchResult(
|
||||
link=result['url'],
|
||||
title=result.get('title'),
|
||||
snippet='\n\n'.join(result.get('snippets', [])),
|
||||
)
|
||||
for result in results[:count]
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.retrieval.web.main import SearchResult, get_filtered_results
|
||||
@@ -25,17 +26,25 @@ def search_duckduckgo(
|
||||
Returns:
|
||||
list[SearchResult]: A list of search results
|
||||
"""
|
||||
# Use the DDGS context manager to create a DDGS object
|
||||
# The ddgs library (primp-based) does not auto-detect proxy env vars.
|
||||
# Resolve via stdlib getproxies() — same pattern as the other loaders.
|
||||
env_proxies = urllib.request.getproxies()
|
||||
proxy = env_proxies.get('https') or env_proxies.get('http')
|
||||
search_results = []
|
||||
with DDGS() as ddgs:
|
||||
with DDGS(proxy=proxy) as ddgs:
|
||||
if concurrent_requests:
|
||||
ddgs.threads = concurrent_requests
|
||||
|
||||
# Use the ddgs.text() method to perform the search
|
||||
try:
|
||||
search_results = ddgs.text(query, safesearch='moderate', max_results=count, backend=backend)
|
||||
kwargs = {'safesearch': 'moderate', 'max_results': count}
|
||||
if backend and backend != 'auto':
|
||||
kwargs['backend'] = backend
|
||||
results = ddgs.text(query, **kwargs)
|
||||
search_results = results if results is not None else []
|
||||
except RatelimitException as e:
|
||||
log.error(f'RatelimitException: {e}')
|
||||
search_results = []
|
||||
if filter_list:
|
||||
search_results = get_filtered_results(search_results, filter_list)
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def search_searxng(
|
||||
"""
|
||||
|
||||
# Default values for optional parameters are provided as empty strings or None when not specified.
|
||||
language = kwargs.get('language', 'all')
|
||||
language = kwargs.get('language', 'all').strip().rstrip(',')
|
||||
safesearch = kwargs.get('safesearch', '1')
|
||||
time_range = kwargs.get('time_range', '')
|
||||
categories = ''.join(kwargs.get('categories', []))
|
||||
|
||||
@@ -48,7 +48,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
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,6 +69,14 @@ def validate_url(url: Union[str, Sequence[str]]):
|
||||
if isinstance(validators.url(url), validators.ValidationError):
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
# Reject parser-confusing chars: urlparse and requests/aiohttp split
|
||||
# on these differently, e.g. http://127.0.0.1\@1.1.1.1 → urlparse
|
||||
# extracts 1.1.1.1 (public, passes filter) while requests connects
|
||||
# to 127.0.0.1 (internal). Same shape with tab/CR/LF.
|
||||
if any(ch in url for ch in ('\\', '\t', '\n', '\r')):
|
||||
log.warning(f'Blocked URL with parser-confusing char: {url!r}')
|
||||
raise ValueError(ERROR_MESSAGES.INVALID_URL)
|
||||
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
|
||||
# Protocol validation - only allow http/https
|
||||
@@ -485,6 +493,17 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.trust_env = trust_env
|
||||
# Prevent redirect-based SSRF on the synchronous _scrape() path.
|
||||
# validate_url() is called once on the originally-submitted URL, but the
|
||||
# parent WebBaseLoader's _scrape() invokes self.session.get(url, **self.requests_kwargs)
|
||||
# which by default follows redirects. Without the override below, an attacker
|
||||
# can submit a public URL that 302-redirects to an internal address (RFC1918,
|
||||
# 127.0.0.1, 169.254.169.254, etc.) and the redirected target is fetched without
|
||||
# re-validation. Matches the policy enforced on the async _fetch() path below.
|
||||
self.requests_kwargs = {
|
||||
**(self.requests_kwargs or {}),
|
||||
'allow_redirects': AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
}
|
||||
|
||||
async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str:
|
||||
async with aiohttp.ClientSession(trust_env=self.trust_env) as session:
|
||||
@@ -502,7 +521,7 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
async with session.get(
|
||||
url,
|
||||
**(self.requests_kwargs | kwargs),
|
||||
allow_redirects=False,
|
||||
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
) as response:
|
||||
if self.raise_for_status:
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -20,6 +20,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def xml_element_contents_to_string(element: Element) -> str:
|
||||
if element is None:
|
||||
return ''
|
||||
buffer = [element.text if element.text else '']
|
||||
|
||||
for child in element:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
import io
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -127,6 +129,54 @@ def convert_audio_to_mp3(file_path):
|
||||
return None
|
||||
|
||||
|
||||
def transcode_audio_to_mp3(audio_data: bytes, content_type_header: str, output_path: str) -> bool:
|
||||
"""
|
||||
Transcode audio bytes to MP3 if the Content-Type indicates a non-MP3 format.
|
||||
|
||||
Handles raw PCM audio (e.g. Gemini-TTS via OpenRouter/LiteLLM) by parsing
|
||||
optional rate/channels from the Content-Type params, defaulting to 24kHz,
|
||||
16-bit, mono. For other non-MP3 formats, uses pydub auto-detection.
|
||||
|
||||
Returns True if transcoding was performed, False if the data is already MP3.
|
||||
Respects BYPASS_PYDUB_PREPROCESSING — when set, writes raw bytes and logs a warning.
|
||||
"""
|
||||
mime_type = content_type_header.split(';')[0].strip().lower()
|
||||
|
||||
if mime_type in ('audio/mpeg', 'audio/mp3'):
|
||||
return False
|
||||
|
||||
if BYPASS_PYDUB_PREPROCESSING:
|
||||
log.warning(
|
||||
f'TTS returned {mime_type} but BYPASS_PYDUB_PREPROCESSING is set; writing raw audio without transcoding'
|
||||
)
|
||||
return False
|
||||
|
||||
if mime_type in ('audio/pcm', 'audio/l16', 'audio/raw'):
|
||||
# Parse optional rate/channels from Content-Type params,
|
||||
# default: 24kHz, 16-bit, mono (standard for Gemini TTS).
|
||||
ct_params = {}
|
||||
for part in content_type_header.split(';')[1:]:
|
||||
key_val = part.strip().split('=')
|
||||
if len(key_val) == 2:
|
||||
ct_params[key_val[0].strip().lower()] = key_val[1].strip()
|
||||
|
||||
sample_rate = int(ct_params.get('rate', 24000))
|
||||
channels = int(ct_params.get('channels', 1))
|
||||
|
||||
audio_segment = AudioSegment.from_raw(
|
||||
io.BytesIO(audio_data),
|
||||
sample_width=2,
|
||||
frame_rate=sample_rate,
|
||||
channels=channels,
|
||||
)
|
||||
else:
|
||||
audio_segment = AudioSegment.from_file(io.BytesIO(audio_data))
|
||||
|
||||
audio_segment.export(str(output_path), format='mp3')
|
||||
log.info(f'Transcoded {mime_type} audio to MP3: {output_path}')
|
||||
return True
|
||||
|
||||
|
||||
def set_faster_whisper_model(model: str, auto_update: bool = False):
|
||||
whisper_model = None
|
||||
if model:
|
||||
@@ -178,6 +228,7 @@ class STTConfigForm(BaseModel):
|
||||
ENGINE: str
|
||||
MODEL: str
|
||||
SUPPORTED_CONTENT_TYPES: list[str] = []
|
||||
ALLOWED_EXTENSIONS: list[str] = []
|
||||
WHISPER_MODEL: str
|
||||
DEEPGRAM_API_KEY: str
|
||||
AZURE_API_KEY: str
|
||||
@@ -219,6 +270,7 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
|
||||
'ENGINE': request.app.state.config.STT_ENGINE,
|
||||
'MODEL': request.app.state.config.STT_MODEL,
|
||||
'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
|
||||
'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS,
|
||||
'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL,
|
||||
'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY,
|
||||
'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
||||
@@ -254,6 +306,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
|
||||
request.app.state.config.STT_ENGINE = form_data.stt.ENGINE
|
||||
request.app.state.config.STT_MODEL = form_data.stt.MODEL
|
||||
request.app.state.config.STT_SUPPORTED_CONTENT_TYPES = form_data.stt.SUPPORTED_CONTENT_TYPES
|
||||
request.app.state.config.STT_ALLOWED_EXTENSIONS = form_data.stt.ALLOWED_EXTENSIONS
|
||||
|
||||
request.app.state.config.WHISPER_MODEL = form_data.stt.WHISPER_MODEL
|
||||
request.app.state.config.DEEPGRAM_API_KEY = form_data.stt.DEEPGRAM_API_KEY
|
||||
@@ -295,6 +348,7 @@ async def update_audio_config(request: Request, form_data: AudioConfigUpdateForm
|
||||
'ENGINE': request.app.state.config.STT_ENGINE,
|
||||
'MODEL': request.app.state.config.STT_MODEL,
|
||||
'SUPPORTED_CONTENT_TYPES': request.app.state.config.STT_SUPPORTED_CONTENT_TYPES,
|
||||
'ALLOWED_EXTENSIONS': request.app.state.config.STT_ALLOWED_EXTENSIONS,
|
||||
'WHISPER_MODEL': request.app.state.config.WHISPER_MODEL,
|
||||
'DEEPGRAM_API_KEY': request.app.state.config.DEEPGRAM_API_KEY,
|
||||
'AZURE_API_KEY': request.app.state.config.AUDIO_STT_AZURE_API_KEY,
|
||||
@@ -387,8 +441,12 @@ async def speech(request: Request, user=Depends(get_verified_user)):
|
||||
|
||||
r.raise_for_status()
|
||||
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(await r.read())
|
||||
audio_data = await r.read()
|
||||
content_type_header = r.headers.get('Content-Type', 'audio/mpeg')
|
||||
|
||||
if not transcode_audio_to_mp3(audio_data, content_type_header, file_path):
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(audio_data)
|
||||
|
||||
async with aiofiles.open(file_body_path, 'w') as f:
|
||||
await f.write(json.dumps(payload))
|
||||
@@ -1107,6 +1165,11 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
|
||||
else:
|
||||
if is_audio_conversion_required(file_path):
|
||||
file_path = convert_audio_to_mp3(file_path)
|
||||
if not file_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Audio conversion failed. The audio file may be corrupted or empty.',
|
||||
)
|
||||
|
||||
try:
|
||||
file_path = compress_audio(file_path)
|
||||
@@ -1126,7 +1189,12 @@ def transcribe(request: Request, file_path: str, metadata: Optional[dict] = None
|
||||
|
||||
results = []
|
||||
try:
|
||||
with ThreadPoolExecutor() as executor:
|
||||
if getattr(request.app.state.config, 'STT_ENGINE', '') == '':
|
||||
max_workers = 1
|
||||
else:
|
||||
max_workers = None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
# Submit tasks for each chunk_path
|
||||
futures = [
|
||||
executor.submit(transcription_handler, request, chunk_path, metadata, user)
|
||||
@@ -1242,12 +1310,19 @@ async def transcription(
|
||||
|
||||
try:
|
||||
safe_name = os.path.basename(file.filename) if file.filename else ''
|
||||
ext = safe_name.rsplit('.', 1)[-1] if '.' in safe_name else ''
|
||||
ext = safe_name.rsplit('.', 1)[-1].lower() if '.' in safe_name else ''
|
||||
|
||||
allowed_extensions = getattr(request.app.state.config, 'STT_ALLOWED_EXTENSIONS', [])
|
||||
if allowed_extensions and ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid audio file extension',
|
||||
)
|
||||
|
||||
id = uuid.uuid4()
|
||||
|
||||
filename = f'{id}.{ext}'
|
||||
contents = file.file.read()
|
||||
contents = await file.read()
|
||||
|
||||
file_dir = os.path.join(CACHE_DIR, 'audio', 'transcriptions')
|
||||
os.makedirs(file_dir, exist_ok=True)
|
||||
@@ -1266,7 +1341,7 @@ async def transcription(
|
||||
if language:
|
||||
metadata = {'language': language}
|
||||
|
||||
result = transcribe(request, file_path, metadata, user)
|
||||
result = await asyncio.to_thread(transcribe, request, file_path, metadata, user)
|
||||
|
||||
return {
|
||||
**result,
|
||||
|
||||
@@ -522,6 +522,18 @@ async def ldap_auth(
|
||||
db=db,
|
||||
)
|
||||
|
||||
if request.app.state.config.WEBHOOK_URL:
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
request.app.state.config.WEBHOOK_URL,
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
'action': 'signup',
|
||||
'message': WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
'user': user.model_dump_json(exclude_none=True),
|
||||
},
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as err:
|
||||
@@ -773,7 +785,7 @@ async def signup(
|
||||
raise HTTPException(500, detail='An internal error occurred during signup.')
|
||||
|
||||
|
||||
@router.get('/signout')
|
||||
@router.post('/signout')
|
||||
async def signout(request: Request, response: Response, db: AsyncSession = Depends(get_async_session)):
|
||||
# get auth token from headers or cookies
|
||||
token = None
|
||||
|
||||
@@ -22,7 +22,7 @@ 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.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.utils.calendar import expand_recurring_event
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
@@ -112,6 +112,17 @@ async def get_calendars(request: Request, user: UserModel = Depends(get_verified
|
||||
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)
|
||||
# Strip public/user grants the requesting user is not permitted to assign
|
||||
# (matches the channel/notes/models pattern). Without this, any verified user
|
||||
# could create a calendar with `principal_id='*' permission='read'|'write'`,
|
||||
# making their events readable or writable by any other verified user.
|
||||
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_calendars',
|
||||
)
|
||||
return await Calendars.insert_new_calendar(user.id, form_data)
|
||||
|
||||
|
||||
@@ -350,6 +361,20 @@ async def update_calendar(
|
||||
if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin':
|
||||
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
|
||||
|
||||
# Strip public/user grants the requesting user is not permitted to assign
|
||||
# (matches the channel/notes/models pattern). The owner-only check above
|
||||
# only restricts WHO can set grants; this filter restricts WHICH grants
|
||||
# they may set, so a non-admin owner cannot make their calendar
|
||||
# publicly readable/writable without the corresponding sharing permission.
|
||||
if form_data.access_grants is not None:
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_calendars',
|
||||
)
|
||||
|
||||
updated = await Calendars.update_calendar_by_id(calendar_id, form_data)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail='Failed to update')
|
||||
|
||||
@@ -57,7 +57,6 @@ from open_webui.utils.models import (
|
||||
get_all_models,
|
||||
get_filtered_models,
|
||||
)
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
@@ -979,57 +978,48 @@ async def model_response_handler(request, channel, message, user, db=None):
|
||||
],
|
||||
]
|
||||
|
||||
# Resolve model config (same helpers automations use)
|
||||
from open_webui.utils.automations import (
|
||||
_resolve_model_tool_ids,
|
||||
_resolve_model_features,
|
||||
_resolve_model_filter_ids,
|
||||
)
|
||||
|
||||
tool_ids = _resolve_model_tool_ids(request.app, model_id)
|
||||
features = _resolve_model_features(request.app, model_id)
|
||||
filter_ids = _resolve_model_filter_ids(request.app, model_id)
|
||||
|
||||
# Build full form_data — same shape as frontend POST.
|
||||
# The channel: prefix routes pipeline events to the
|
||||
# channel emitter in socket/main.py instead of the
|
||||
# default chat emitter.
|
||||
form_data = {
|
||||
'model': model_id,
|
||||
'messages': [
|
||||
system_message,
|
||||
{'role': 'user', 'content': content},
|
||||
],
|
||||
'stream': False,
|
||||
'stream': True,
|
||||
'chat_id': f'channel:{channel.id}',
|
||||
'id': response_message.id,
|
||||
'session_id': f'channel:{channel.id}',
|
||||
'background_tasks': {},
|
||||
}
|
||||
if tool_ids:
|
||||
form_data['tool_ids'] = tool_ids
|
||||
if features:
|
||||
form_data['features'] = features
|
||||
if filter_ids:
|
||||
form_data['filter_ids'] = filter_ids
|
||||
|
||||
res = await generate_chat_completion(
|
||||
request,
|
||||
form_data=form_data,
|
||||
user=user,
|
||||
)
|
||||
# Call the full chat completion pipeline — streaming,
|
||||
# tools, filters, RAG — everything. The pipeline runs as
|
||||
# an async task; the channel emitter handles progressive
|
||||
# message updates via socket events.
|
||||
await request.app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
|
||||
|
||||
if res:
|
||||
if res.get('choices', []) and len(res['choices']) > 0:
|
||||
await update_message_by_id(
|
||||
request,
|
||||
channel.id,
|
||||
response_message.id,
|
||||
MessageForm(
|
||||
**{
|
||||
'content': res['choices'][0]['message']['content'],
|
||||
'meta': {
|
||||
'done': True,
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
db,
|
||||
)
|
||||
elif res.get('error', None):
|
||||
await update_message_by_id(
|
||||
request,
|
||||
channel.id,
|
||||
response_message.id,
|
||||
MessageForm(
|
||||
**{
|
||||
'content': f'Error: {res["error"]}',
|
||||
'meta': {
|
||||
'done': True,
|
||||
},
|
||||
}
|
||||
),
|
||||
user,
|
||||
db,
|
||||
)
|
||||
except Exception as e:
|
||||
log.info(e)
|
||||
pass
|
||||
log.exception(e)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1256,7 +1246,8 @@ async def pin_channel_message(
|
||||
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='read', db=db):
|
||||
# Pin/unpin mutates is_pinned/pinned_by/pinned_at — require write.
|
||||
if user.role != 'admin' and not await channel_has_access(user.id, channel, permission='write', db=db):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
message = await Messages.get_message_by_id(message_id, db=db)
|
||||
@@ -1368,6 +1359,9 @@ async def update_message_by_id(
|
||||
if channel.type in ['group', 'dm']:
|
||||
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
# Membership is not authorship — block cross-member edits.
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if (
|
||||
user.role != 'admin'
|
||||
@@ -1569,6 +1563,9 @@ async def delete_message_by_id(
|
||||
if channel.type in ['group', 'dm']:
|
||||
if not await Channels.is_user_channel_member(channel.id, user.id, db=db):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
# Membership is not authorship — block cross-member deletes.
|
||||
if user.role != 'admin' and message.user_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
else:
|
||||
if (
|
||||
user.role != 'admin'
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.middleware import serialize_output
|
||||
from open_webui.socket.main import get_event_emitter
|
||||
from open_webui.models.chats import (
|
||||
ChatForm,
|
||||
@@ -676,11 +677,46 @@ async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSessio
|
||||
# GetChats
|
||||
############################
|
||||
|
||||
CHAT_EXPORT_BATCH_SIZE = 100
|
||||
|
||||
@router.get('/all', response_model=list[ChatResponse])
|
||||
async def get_user_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
result = await Chats.get_chats_by_user_id(user.id, db=db)
|
||||
return [ChatResponse(**chat.model_dump()) for chat in result.items]
|
||||
|
||||
async def generate_chat_export_ndjson(user_id: str):
|
||||
"""
|
||||
Async generator that streams all user chats as NDJSON (one JSON object per line).
|
||||
|
||||
Uses short-lived DB sessions per batch to avoid holding locks for the
|
||||
entire duration, which is critical for SQLite environments.
|
||||
"""
|
||||
skip = 0
|
||||
|
||||
while True:
|
||||
result = await Chats.get_chats_by_user_id(
|
||||
user_id,
|
||||
skip=skip,
|
||||
limit=CHAT_EXPORT_BATCH_SIZE,
|
||||
db=None,
|
||||
)
|
||||
if not result.items:
|
||||
break
|
||||
|
||||
for chat in result.items:
|
||||
try:
|
||||
yield ChatResponse(**chat.model_dump()).model_dump_json() + '\n'
|
||||
except Exception as e:
|
||||
log.exception(f'Error serializing chat {chat.id}: {e}')
|
||||
|
||||
if len(result.items) < CHAT_EXPORT_BATCH_SIZE:
|
||||
break
|
||||
|
||||
skip += CHAT_EXPORT_BATCH_SIZE
|
||||
|
||||
|
||||
@router.get('/all')
|
||||
async def get_user_chats(user=Depends(get_verified_user)):
|
||||
return StreamingResponse(
|
||||
generate_chat_export_ndjson(user.id),
|
||||
media_type='application/x-ndjson',
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -829,29 +865,31 @@ 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 == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_share_id(share_id, db=db)
|
||||
|
||||
# Fallback: admins can also access any chat directly by chat ID
|
||||
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(share_id, db=db)
|
||||
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,
|
||||
# Look up the original chat_id to check access grants (admins bypass)
|
||||
if user.role != 'admin' or not ENABLE_ADMIN_CHAT_ACCESS:
|
||||
shared = await SharedChats.get_by_id(share_id, db=db)
|
||||
if shared and shared.user_id != user.id:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
resource_id=shared.chat_id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
if not has_grant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
|
||||
@@ -930,6 +968,14 @@ async def update_chat_by_id(
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if chat:
|
||||
updated_chat = {**chat.chat, **form_data.chat}
|
||||
|
||||
# Re-derive content from output for assistant messages so that
|
||||
# frontend edits to output items are always reflected in content.
|
||||
# serialize_output() is the single source of truth for this conversion.
|
||||
for msg in updated_chat.get('history', {}).get('messages', {}).values():
|
||||
if msg.get('role') == 'assistant' and msg.get('output'):
|
||||
msg['content'] = serialize_output(msg['output'])
|
||||
|
||||
chat = await Chats.update_chat_by_id(id, updated_chat, db=db)
|
||||
return ChatResponse(**chat.model_dump())
|
||||
else:
|
||||
@@ -1183,10 +1229,11 @@ async def clone_chat_by_id(
|
||||
async def clone_shared_chat_by_id(
|
||||
id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
if user.role == 'admin':
|
||||
chat = await Chats.get_chat_by_share_id(id, db=db)
|
||||
|
||||
# Fallback: admins can also access any chat directly by chat ID
|
||||
if not chat and user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
else:
|
||||
chat = await Chats.get_chat_by_share_id(id, db=db)
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
@@ -1194,9 +1241,9 @@ async def clone_shared_chat_by_id(
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
# Enforce access grants
|
||||
# Enforce access grants (owner and admins bypass)
|
||||
shared = await SharedChats.get_by_id(id, db=db)
|
||||
if shared and user.role != 'admin':
|
||||
if shared and user.role != 'admin' and shared.user_id != user.id:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='shared_chat',
|
||||
@@ -1365,19 +1412,16 @@ async def update_shared_chat_access_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if user.role == 'admin':
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
else:
|
||||
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,
|
||||
@@ -1402,19 +1446,16 @@ async def get_shared_chat_access_by_id(
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if user.role == 'admin':
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
else:
|
||||
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 [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Optional
|
||||
|
||||
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.utils.headers import get_custom_headers
|
||||
from open_webui.config import get_config, save_config, async_save_config
|
||||
from open_webui.config import BannerModel
|
||||
|
||||
@@ -49,8 +50,9 @@ class ImportConfigForm(BaseModel):
|
||||
|
||||
|
||||
@router.post('/import', response_model=dict)
|
||||
async def import_config(form_data: ImportConfigForm, user=Depends(get_admin_user)):
|
||||
async def import_config(request: Request, form_data: ImportConfigForm, user=Depends(get_admin_user)):
|
||||
await async_save_config(form_data.config)
|
||||
request.app.state.config._sync_to_redis()
|
||||
return get_config()
|
||||
|
||||
|
||||
@@ -102,6 +104,7 @@ class OAuthClientRegistrationForm(BaseModel):
|
||||
client_id: str
|
||||
client_name: Optional[str] = None
|
||||
client_secret: Optional[str] = None
|
||||
oauth_server_url: Optional[str] = None
|
||||
|
||||
|
||||
@router.post('/oauth/clients/register')
|
||||
@@ -116,18 +119,20 @@ async def register_oauth_client(
|
||||
if type:
|
||||
oauth_client_id = f'{type}:{form_data.client_id}'
|
||||
|
||||
oauth_server_url = form_data.oauth_server_url if form_data.oauth_server_url else form_data.url
|
||||
|
||||
if form_data.client_secret:
|
||||
# Static credentials: skip dynamic registration, build from provided credentials
|
||||
oauth_client_info = await get_oauth_client_info_with_static_credentials(
|
||||
request,
|
||||
oauth_client_id,
|
||||
form_data.url,
|
||||
oauth_server_url,
|
||||
oauth_client_id=form_data.client_id,
|
||||
oauth_client_secret=form_data.client_secret,
|
||||
)
|
||||
else:
|
||||
oauth_client_info = await get_oauth_client_info_with_dynamic_client_registration(
|
||||
request, oauth_client_id, form_data.url
|
||||
request, oauth_client_id, oauth_server_url
|
||||
)
|
||||
return {
|
||||
'status': True,
|
||||
@@ -154,6 +159,7 @@ class ToolServerConnection(BaseModel):
|
||||
headers: Optional[dict | str] = None
|
||||
key: Optional[str]
|
||||
config: Optional[dict]
|
||||
info: Optional[dict] = None
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
@@ -368,7 +374,12 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
||||
try:
|
||||
if form_data.type == 'mcp':
|
||||
if form_data.auth_type in ('oauth_2.1', 'oauth_2.1_static'):
|
||||
discovery_urls = await get_discovery_urls(form_data.url)
|
||||
oauth_server_url = (
|
||||
form_data.info.get('oauth_server_url')
|
||||
if form_data.info and form_data.info.get('oauth_server_url')
|
||||
else form_data.url
|
||||
)
|
||||
discovery_urls = await get_discovery_urls(oauth_server_url)
|
||||
for discovery_url in discovery_urls:
|
||||
log.debug(f'Trying to fetch OAuth 2.1 discovery document from {discovery_url}')
|
||||
async with aiohttp.ClientSession(
|
||||
@@ -427,7 +438,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
||||
if form_data.headers and isinstance(form_data.headers, dict):
|
||||
if headers is None:
|
||||
headers = {}
|
||||
headers.update(form_data.headers)
|
||||
custom_headers = get_custom_headers(form_data.headers, user)
|
||||
headers.update(custom_headers)
|
||||
|
||||
await client.connect(form_data.url, headers=headers)
|
||||
specs = await client.list_tool_specs()
|
||||
@@ -471,7 +483,8 @@ async def verify_tool_servers_config(request: Request, form_data: ToolServerConn
|
||||
if form_data.headers and isinstance(form_data.headers, dict):
|
||||
if headers is None:
|
||||
headers = {}
|
||||
headers.update(form_data.headers)
|
||||
custom_headers = get_custom_headers(form_data.headers, user)
|
||||
headers.update(custom_headers)
|
||||
|
||||
url = get_tool_server_url(form_data.url, form_data.path)
|
||||
return await get_tool_server_data(url, headers=headers)
|
||||
|
||||
@@ -125,7 +125,13 @@ async def process_uploaded_file(
|
||||
|
||||
if strict_match_mime_type(stt_supported_content_types, content_type):
|
||||
file_path_processed = await asyncio.to_thread(Storage.get_file, file_path)
|
||||
result = transcribe(request, file_path_processed, file_metadata, user)
|
||||
result = await asyncio.to_thread(
|
||||
transcribe,
|
||||
request,
|
||||
file_path_processed,
|
||||
file_metadata,
|
||||
user,
|
||||
)
|
||||
|
||||
await process_file(
|
||||
request,
|
||||
|
||||
@@ -16,8 +16,6 @@ from open_webui.models.folders import (
|
||||
Folders,
|
||||
)
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
|
||||
|
||||
from open_webui.config import UPLOAD_DIR
|
||||
@@ -32,6 +30,7 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
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.files import get_accessible_folder_files
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -75,20 +74,10 @@ async def get_folders(
|
||||
if folder.parent_id and not await Folders.get_folder_by_id_and_user_id(folder.parent_id, user.id, db=db):
|
||||
folder = await Folders.update_folder_parent_id_by_id_and_user_id(folder.id, user.id, None, db=db)
|
||||
|
||||
if folder.data:
|
||||
if 'files' in folder.data:
|
||||
valid_files = []
|
||||
for file in folder.data['files']:
|
||||
if file.get('type') == 'file':
|
||||
if await Files.check_access_by_user_id(file.get('id'), user.id, 'read', db=db):
|
||||
valid_files.append(file)
|
||||
elif file.get('type') == 'collection':
|
||||
if await Knowledges.check_access_by_user_id(file.get('id'), user.id, 'read', db=db):
|
||||
valid_files.append(file)
|
||||
else:
|
||||
valid_files.append(file)
|
||||
|
||||
folder.data['files'] = valid_files
|
||||
if folder.data and 'files' in folder.data:
|
||||
accessible_files = await get_accessible_folder_files(folder.data['files'], user, db=db)
|
||||
if len(accessible_files) != len(folder.data.get('files', [])):
|
||||
folder.data['files'] = accessible_files
|
||||
await Folders.update_folder_by_id_and_user_id(
|
||||
folder.id, user.id, FolderUpdateForm(data=folder.data), db=db
|
||||
)
|
||||
@@ -173,6 +162,16 @@ async def update_folder_name_by_id(
|
||||
detail=ERROR_MESSAGES.DEFAULT('Folder already exists'),
|
||||
)
|
||||
|
||||
# Validate read access to every file/collection being attached.
|
||||
# Folder files are consumed by chat middleware as RAG context.
|
||||
if form_data.data and isinstance(form_data.data.get('files'), list):
|
||||
accessible_files = await get_accessible_folder_files(form_data.data['files'], user, db=db)
|
||||
if len(accessible_files) != len(form_data.data['files']):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
try:
|
||||
folder = await Folders.update_folder_by_id_and_user_id(id, user.id, form_data, db=db)
|
||||
return folder
|
||||
|
||||
@@ -22,7 +22,7 @@ from open_webui.config import (
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_ALLOW_REDIRECTS, ENABLE_FORWARD_USER_INFO_HEADERS
|
||||
from open_webui.utils.session_pool import get_session
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
@@ -442,6 +442,8 @@ GenerateImageForm = CreateImageForm # Alias for backward compatibility
|
||||
async def get_image_data(data: str, headers=None):
|
||||
try:
|
||||
if data.startswith('http://') or data.startswith('https://'):
|
||||
# Defense-in-depth: gate before fetch (mirrors load_url_image).
|
||||
validate_url(data)
|
||||
session = await get_session()
|
||||
async with session.get(
|
||||
data,
|
||||
@@ -807,10 +809,16 @@ async def image_edits(
|
||||
return data
|
||||
|
||||
if data.startswith('http://') or data.startswith('https://'):
|
||||
# Validate URL to prevent SSRF attacks against local/private networks
|
||||
# Validate URL to prevent SSRF attacks against local/private networks.
|
||||
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
|
||||
# called only on the originally-submitted URL; following 3xx redirects
|
||||
# without re-validation would let an attacker reach private IPs via a
|
||||
# public host that redirects internally (e.g. cloud-metadata exfil).
|
||||
validate_url(data)
|
||||
session = await get_session()
|
||||
async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
|
||||
async with session.get(
|
||||
data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
) as r:
|
||||
r.raise_for_status()
|
||||
|
||||
image_data = base64.b64encode(await r.read()).decode('utf-8')
|
||||
|
||||
@@ -31,6 +31,7 @@ from open_webui.storage.provider import Storage
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_verified_user, get_admin_user
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
|
||||
@@ -656,6 +657,14 @@ async def add_file_to_knowledge_by_id(
|
||||
detail=ERROR_MESSAGES.FILE_NOT_PROCESSED,
|
||||
)
|
||||
|
||||
# KB write-access alone is not enough — caller must also be able to read the file.
|
||||
if file.user_id != user.id and user.role != 'admin':
|
||||
if not await has_access_to_file(file.id, 'read', user, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Add content to the vector database
|
||||
try:
|
||||
await process_file(
|
||||
@@ -1017,6 +1026,15 @@ async def add_files_to_knowledge_batch(
|
||||
detail=f'File {missing_ids[0]} not found',
|
||||
)
|
||||
|
||||
# Per-file read-access check — same gate as the single-file endpoint.
|
||||
if user.role != 'admin':
|
||||
for file in files:
|
||||
if file.user_id != user.id and not await has_access_to_file(file.id, 'read', user, db=db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
# Process files
|
||||
try:
|
||||
result = await process_files_batch(
|
||||
|
||||
@@ -28,8 +28,8 @@ from fastapi import (
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
status,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
|
||||
@@ -37,6 +37,7 @@ from fastapi.responses import RedirectResponse, StreamingResponse
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING
|
||||
from open_webui.internal.db import get_async_session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -412,30 +413,37 @@ class ModelIdForm(BaseModel):
|
||||
async def get_model_by_id(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:
|
||||
if (
|
||||
write_access = (
|
||||
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or model.user_id == user.id
|
||||
or user.id == model.user_id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='model',
|
||||
resource_id=model.id,
|
||||
permission='read',
|
||||
permission='write',
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
|
||||
if write_access or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='model',
|
||||
resource_id=model.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
):
|
||||
model_dict = model.model_dump()
|
||||
# Strip params (system prompt and other admin-curated config)
|
||||
# for read-only callers — matches the params strip already
|
||||
# enforced on /api/models in utils/models.py. Owners, admins
|
||||
# under BYPASS_ADMIN_ACCESS_CONTROL, and write-grant holders
|
||||
# still receive the full object so the workspace edit UI keeps
|
||||
# working for users who legitimately curate the model.
|
||||
if not write_access:
|
||||
model_dict['params'] = {}
|
||||
return ModelAccessResponse(
|
||||
**model.model_dump(),
|
||||
write_access=(
|
||||
(user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
|
||||
or user.id == model.user_id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='model',
|
||||
resource_id=model.id,
|
||||
permission='write',
|
||||
db=db,
|
||||
)
|
||||
),
|
||||
**model_dict,
|
||||
write_access=write_access,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -456,47 +464,67 @@ 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(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
model_meta = await Models.get_model_meta_by_id(id, db=db)
|
||||
profile_image_url = None
|
||||
updated_at = None
|
||||
|
||||
# First, check the database for regular models
|
||||
model_meta = await Models.get_model_meta_by_id(id, db=db)
|
||||
if model_meta:
|
||||
meta, updated_at = model_meta
|
||||
profile_image_url = (meta or {}).get('profile_image_url')
|
||||
|
||||
if profile_image_url:
|
||||
if profile_image_url.startswith('http'):
|
||||
# Fallback: check arena models stored in config (not in the DB)
|
||||
if not profile_image_url:
|
||||
arena_models = getattr(
|
||||
getattr(request.app.state, 'config', None),
|
||||
'EVALUATION_ARENA_MODELS',
|
||||
[],
|
||||
)
|
||||
for arena_model in arena_models:
|
||||
if arena_model.get('id') == id:
|
||||
profile_image_url = arena_model.get('meta', {}).get('profile_image_url')
|
||||
break
|
||||
|
||||
if profile_image_url:
|
||||
if profile_image_url.startswith('http'):
|
||||
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
|
||||
return Response(
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
headers={'Location': profile_image_url},
|
||||
)
|
||||
elif profile_image_url.startswith('data:image'):
|
||||
try:
|
||||
header, base64_data = profile_image_url.split(',', 1)
|
||||
image_data = base64.b64decode(base64_data)
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
media_type = header.split(';')[0].lstrip('data:')
|
||||
# When forwarding is disabled, fall through to the
|
||||
# default image to prevent client-side IP/UA/Referer
|
||||
# leaks via 302 redirect to external origins.
|
||||
elif profile_image_url.startswith('data:image'):
|
||||
try:
|
||||
header, base64_data = profile_image_url.split(',', 1)
|
||||
image_data = base64.b64decode(base64_data)
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
media_type = header.split(';')[0].lstrip('data:')
|
||||
|
||||
headers = {'Content-Disposition': 'inline'}
|
||||
if updated_at:
|
||||
headers['ETag'] = f'"{updated_at}"'
|
||||
headers = {'Content-Disposition': 'inline'}
|
||||
if updated_at:
|
||||
headers['ETag'] = f'"{updated_at}"'
|
||||
|
||||
return StreamingResponse(
|
||||
image_buffer,
|
||||
media_type=media_type,
|
||||
headers=headers,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
safe_static = _safe_static_redirect_path(profile_image_url)
|
||||
if safe_static:
|
||||
return RedirectResponse(
|
||||
url=safe_static,
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
)
|
||||
return StreamingResponse(
|
||||
image_buffer,
|
||||
media_type=media_type,
|
||||
headers=headers,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
safe_static = _safe_static_redirect_path(profile_image_url)
|
||||
if safe_static:
|
||||
return RedirectResponse(
|
||||
url=safe_static,
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
url='/static/favicon.png',
|
||||
|
||||
@@ -92,10 +92,13 @@ async def get_notes(
|
||||
user_ids = list(set(note.user_id for note in notes))
|
||||
users = {user.id: user for user in await Users.get_users_by_user_ids(user_ids, db=db)}
|
||||
|
||||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
|
||||
return [
|
||||
NoteUserResponse(
|
||||
**{
|
||||
**note.model_dump(),
|
||||
'is_pinned': note.id in pinned_note_ids,
|
||||
'data': _truncate_note_data(note.data),
|
||||
'user': UserResponse(**users[note.user_id].model_dump()),
|
||||
}
|
||||
@@ -135,6 +138,7 @@ async def get_pinned_notes(
|
||||
NoteUserResponse(
|
||||
**{
|
||||
**note.model_dump(),
|
||||
'is_pinned': True,
|
||||
'data': _truncate_note_data(note.data),
|
||||
'user': UserResponse(**users[note.user_id].model_dump()),
|
||||
}
|
||||
@@ -190,7 +194,9 @@ async def search_notes(
|
||||
filter['user_id'] = user.id
|
||||
|
||||
result = await Notes.search_notes(user.id, filter, skip=skip, limit=limit, db=db)
|
||||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
for note in result.items:
|
||||
note.is_pinned = note.id in pinned_note_ids
|
||||
note.data = _truncate_note_data(note.data)
|
||||
return result
|
||||
|
||||
@@ -287,7 +293,11 @@ async def get_note_by_id(
|
||||
or has_public_write_access_grant(note.access_grants)
|
||||
)
|
||||
|
||||
return NoteResponse(**note.model_dump(), write_access=write_access)
|
||||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
return NoteResponse(
|
||||
**{**note.model_dump(), 'is_pinned': note.id in pinned_note_ids},
|
||||
write_access=write_access,
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
@@ -338,6 +348,9 @@ async def update_note_by_id(
|
||||
|
||||
try:
|
||||
note = await Notes.update_note_by_id(id, form_data, db=db)
|
||||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
note.is_pinned = note.id in pinned_note_ids
|
||||
|
||||
await sio.emit(
|
||||
'note-events',
|
||||
note.model_dump(),
|
||||
@@ -401,7 +414,10 @@ async def update_note_access_by_id(
|
||||
|
||||
await AccessGrants.set_access_grants('note', id, form_data.access_grants, db=db)
|
||||
|
||||
return await Notes.get_note_by_id(id, db=db)
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
note.is_pinned = note.id in pinned_note_ids
|
||||
return note
|
||||
|
||||
|
||||
############################
|
||||
@@ -440,7 +456,9 @@ async def pin_note_by_id(
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
note = await Notes.toggle_note_pinned_by_id(id, db=db)
|
||||
note = await Notes.toggle_note_pinned_by_id(id, user.id, db=db)
|
||||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
note.is_pinned = note.id in pinned_note_ids
|
||||
return note
|
||||
|
||||
|
||||
|
||||
@@ -1127,7 +1127,7 @@ async def generate_chat_completion(
|
||||
|
||||
payload = apply_model_params_to_body_ollama(params, payload)
|
||||
if not bypass_system_prompt:
|
||||
payload = apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
payload = await apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
await check_model_access(user, model_info, bypass_filter)
|
||||
else:
|
||||
@@ -1282,7 +1282,7 @@ async def generate_openai_chat_completion(
|
||||
system = params.pop('system', None)
|
||||
|
||||
payload = apply_model_params_to_body_openai(params, payload)
|
||||
payload = apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
payload = await apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
await check_model_access(user, model_info)
|
||||
else:
|
||||
|
||||
@@ -62,7 +62,7 @@ from open_webui.utils.session_pool import (
|
||||
)
|
||||
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.headers import include_user_info_headers, get_custom_headers
|
||||
from open_webui.utils.anthropic import is_anthropic_url, get_anthropic_models
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -215,7 +215,8 @@ async def get_headers_and_cookies(
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
|
||||
if config.get('headers') and isinstance(config.get('headers'), dict):
|
||||
headers = {**headers, **config.get('headers')}
|
||||
custom_headers = get_custom_headers(config.get('headers'), user, metadata)
|
||||
headers.update(custom_headers)
|
||||
|
||||
return headers, cookies
|
||||
|
||||
@@ -439,6 +440,7 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
connection_type = api_config.get('connection_type', 'external')
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
tags = api_config.get('tags', [])
|
||||
provider = api_config.get('provider', '')
|
||||
|
||||
model_list = response if isinstance(response, list) else response.get('data', [])
|
||||
if not isinstance(model_list, list):
|
||||
@@ -459,6 +461,9 @@ async def get_all_models_responses(request: Request, user: UserModel) -> list:
|
||||
if connection_type:
|
||||
model['connection_type'] = connection_type
|
||||
|
||||
if provider:
|
||||
model['provider'] = provider
|
||||
|
||||
log.debug(f'get_all_models:responses() {responses}')
|
||||
return responses
|
||||
|
||||
@@ -488,6 +493,39 @@ async def get_filtered_models(models, user, db=None):
|
||||
return filtered_models
|
||||
|
||||
|
||||
async def get_openai_loaded_models(request: Request, models: dict, api_base_urls: list):
|
||||
"""
|
||||
Fetch loaded-model state from providers that expose it and annotate
|
||||
each model dict with a ``loaded`` boolean.
|
||||
|
||||
Currently supports:
|
||||
- **llama.cpp** – queries ``GET /slots`` and matches slot model IDs.
|
||||
"""
|
||||
api_configs = request.app.state.config.OPENAI_API_CONFIGS
|
||||
api_keys = request.app.state.config.OPENAI_API_KEYS
|
||||
|
||||
for idx, url in enumerate(api_base_urls):
|
||||
api_config = api_configs.get(
|
||||
str(idx),
|
||||
api_configs.get(url, {}),
|
||||
)
|
||||
provider = api_config.get('provider', '')
|
||||
|
||||
if provider == 'llama.cpp':
|
||||
try:
|
||||
root_url = url.rstrip('/').removesuffix('/v1')
|
||||
key = api_keys[idx] if idx < len(api_keys) else None
|
||||
slots = await send_get_request(url=f'{root_url}/slots', key=key)
|
||||
loaded_model_ids = (
|
||||
{s.get('model') for s in slots if s.get('model')} if isinstance(slots, list) else set()
|
||||
)
|
||||
for model_id, model in models.items():
|
||||
if model.get('urlIdx') == idx:
|
||||
model['loaded'] = model_id in loaded_model_ids
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to fetch llama.cpp slots for idx {idx}: {e}')
|
||||
|
||||
|
||||
@cached(
|
||||
ttl=MODELS_CACHE_TTL,
|
||||
key=lambda _, user: f'openai_all_models_{user.id}' if user else 'openai_all_models',
|
||||
@@ -548,6 +586,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
'owned_by': 'openai',
|
||||
'openai': model,
|
||||
'connection_type': model.get('connection_type', 'external'),
|
||||
'provider': model.get('provider', ''),
|
||||
'urlIdx': idx,
|
||||
}
|
||||
|
||||
@@ -556,6 +595,9 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
||||
models = get_merged_models(map(extract_data, responses))
|
||||
log.debug(f'models: {models}')
|
||||
|
||||
# Fetch loaded state for providers that support it (e.g. llama.cpp /slots)
|
||||
await get_openai_loaded_models(request, models, api_base_urls)
|
||||
|
||||
request.app.state.OPENAI_MODELS = models
|
||||
return {'data': list(models.values())}
|
||||
|
||||
@@ -1077,7 +1119,7 @@ async def generate_chat_completion(
|
||||
|
||||
payload = apply_model_params_to_body_openai(params, payload)
|
||||
if not bypass_system_prompt:
|
||||
payload = apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
payload = await apply_system_prompt_to_body(system, payload, metadata, user)
|
||||
|
||||
await check_model_access(user, model_info, bypass_filter)
|
||||
else:
|
||||
|
||||
@@ -61,13 +61,7 @@ async def get_prompts(user=Depends(get_verified_user), db: AsyncSession = Depend
|
||||
async def get_prompt_tags(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
return await Prompts.get_tags(db=db)
|
||||
else:
|
||||
prompts = await Prompts.get_prompts_by_user_id(user.id, 'read', db=db)
|
||||
tags = set()
|
||||
for prompt in prompts:
|
||||
if prompt.tags:
|
||||
tags.update(prompt.tags)
|
||||
return sorted(list(tags))
|
||||
return await Prompts.get_tags_by_user_id(user.id, db=db)
|
||||
|
||||
|
||||
@router.get('/list', response_model=PromptAccessListResponse)
|
||||
|
||||
@@ -57,6 +57,7 @@ from open_webui.retrieval.web.utils import get_web_loader
|
||||
from open_webui.retrieval.web.ollama import search_ollama_cloud
|
||||
from open_webui.retrieval.web.perplexity_search import search_perplexity_search
|
||||
from open_webui.retrieval.web.brave import search_brave
|
||||
from open_webui.retrieval.web.brave_llm_context import search_brave_llm_context
|
||||
from open_webui.retrieval.web.kagi import search_kagi
|
||||
from open_webui.retrieval.web.mojeek import search_mojeek
|
||||
from open_webui.retrieval.web.bocha import search_bocha
|
||||
@@ -259,22 +260,6 @@ class SearchForm(BaseModel):
|
||||
queries: List[str]
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def get_status(request: Request):
|
||||
return {
|
||||
'status': True,
|
||||
'CHUNK_SIZE': request.app.state.config.CHUNK_SIZE,
|
||||
'CHUNK_OVERLAP': request.app.state.config.CHUNK_OVERLAP,
|
||||
'RAG_TEMPLATE': request.app.state.config.RAG_TEMPLATE,
|
||||
'RAG_EMBEDDING_ENGINE': request.app.state.config.RAG_EMBEDDING_ENGINE,
|
||||
'RAG_EMBEDDING_MODEL': request.app.state.config.RAG_EMBEDDING_MODEL,
|
||||
'RAG_RERANKING_MODEL': request.app.state.config.RAG_RERANKING_MODEL,
|
||||
'RAG_EMBEDDING_BATCH_SIZE': request.app.state.config.RAG_EMBEDDING_BATCH_SIZE,
|
||||
'ENABLE_ASYNC_EMBEDDING': request.app.state.config.ENABLE_ASYNC_EMBEDDING,
|
||||
'RAG_EMBEDDING_CONCURRENT_REQUESTS': request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS,
|
||||
}
|
||||
|
||||
|
||||
@router.get('/embedding')
|
||||
async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
@@ -350,7 +335,7 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd
|
||||
unload_embedding_model(request)
|
||||
try:
|
||||
request.app.state.config.RAG_EMBEDDING_ENGINE = form_data.RAG_EMBEDDING_ENGINE
|
||||
request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL
|
||||
request.app.state.config.RAG_EMBEDDING_MODEL = form_data.RAG_EMBEDDING_MODEL.strip()
|
||||
request.app.state.config.RAG_EMBEDDING_BATCH_SIZE = form_data.RAG_EMBEDDING_BATCH_SIZE
|
||||
request.app.state.config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING
|
||||
request.app.state.config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS
|
||||
@@ -531,6 +516,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY,
|
||||
'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID,
|
||||
'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY,
|
||||
'BRAVE_SEARCH_CONTEXT_TOKENS': request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS,
|
||||
'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY,
|
||||
'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY,
|
||||
'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY,
|
||||
@@ -599,6 +585,7 @@ class WebConfig(BaseModel):
|
||||
GOOGLE_PSE_API_KEY: Optional[str] = None
|
||||
GOOGLE_PSE_ENGINE_ID: Optional[str] = None
|
||||
BRAVE_SEARCH_API_KEY: Optional[str] = None
|
||||
BRAVE_SEARCH_CONTEXT_TOKENS: Optional[int] = None
|
||||
KAGI_SEARCH_API_KEY: Optional[str] = None
|
||||
MOJEEK_SEARCH_API_KEY: Optional[str] = None
|
||||
BOCHA_SEARCH_API_KEY: Optional[str] = None
|
||||
@@ -1081,6 +1068,8 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
||||
request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY
|
||||
request.app.state.config.GOOGLE_PSE_ENGINE_ID = form_data.web.GOOGLE_PSE_ENGINE_ID
|
||||
request.app.state.config.BRAVE_SEARCH_API_KEY = form_data.web.BRAVE_SEARCH_API_KEY
|
||||
if form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS is not None:
|
||||
request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS = form_data.web.BRAVE_SEARCH_CONTEXT_TOKENS
|
||||
request.app.state.config.KAGI_SEARCH_API_KEY = form_data.web.KAGI_SEARCH_API_KEY
|
||||
request.app.state.config.MOJEEK_SEARCH_API_KEY = form_data.web.MOJEEK_SEARCH_API_KEY
|
||||
request.app.state.config.BOCHA_SEARCH_API_KEY = form_data.web.BOCHA_SEARCH_API_KEY
|
||||
@@ -1216,6 +1205,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
|
||||
'GOOGLE_PSE_API_KEY': request.app.state.config.GOOGLE_PSE_API_KEY,
|
||||
'GOOGLE_PSE_ENGINE_ID': request.app.state.config.GOOGLE_PSE_ENGINE_ID,
|
||||
'BRAVE_SEARCH_API_KEY': request.app.state.config.BRAVE_SEARCH_API_KEY,
|
||||
'BRAVE_SEARCH_CONTEXT_TOKENS': request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS,
|
||||
'KAGI_SEARCH_API_KEY': request.app.state.config.KAGI_SEARCH_API_KEY,
|
||||
'MOJEEK_SEARCH_API_KEY': request.app.state.config.MOJEEK_SEARCH_API_KEY,
|
||||
'BOCHA_SEARCH_API_KEY': request.app.state.config.BOCHA_SEARCH_API_KEY,
|
||||
@@ -1577,6 +1567,8 @@ async def process_file(
|
||||
|
||||
if collection_name is None:
|
||||
collection_name = f'file-{file.id}'
|
||||
else:
|
||||
await _validate_collection_access([collection_name], user, access_type='write')
|
||||
|
||||
if form_data.content:
|
||||
# Update the content in the file
|
||||
@@ -1963,6 +1955,17 @@ def search_web(request: Request, engine: str, query: str, user=None) -> list[Sea
|
||||
)
|
||||
else:
|
||||
raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables')
|
||||
elif engine == 'brave_llm_context':
|
||||
if request.app.state.config.BRAVE_SEARCH_API_KEY:
|
||||
return search_brave_llm_context(
|
||||
request.app.state.config.BRAVE_SEARCH_API_KEY,
|
||||
query,
|
||||
request.app.state.config.WEB_SEARCH_RESULT_COUNT,
|
||||
request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
|
||||
request.app.state.config.BRAVE_SEARCH_CONTEXT_TOKENS,
|
||||
)
|
||||
else:
|
||||
raise Exception('No BRAVE_SEARCH_API_KEY found in environment variables')
|
||||
elif engine == 'kagi':
|
||||
if request.app.state.config.KAGI_SEARCH_API_KEY:
|
||||
return search_kagi(
|
||||
@@ -2616,6 +2619,9 @@ async def process_files_batch(
|
||||
|
||||
collection_name = form_data.collection_name
|
||||
|
||||
if collection_name:
|
||||
await _validate_collection_access([collection_name], user, access_type='write')
|
||||
|
||||
file_results: List[BatchProcessFilesResult] = []
|
||||
file_errors: List[BatchProcessFilesResult] = []
|
||||
file_updates: List[FileUpdateForm] = []
|
||||
|
||||
@@ -176,6 +176,19 @@ async def create_new_skill(
|
||||
detail=ERROR_MESSAGES.ID_TAKEN,
|
||||
)
|
||||
|
||||
# Strip public/user grants the requesting user is not permitted to assign
|
||||
# (matches the channel/notes/calendar pattern). Without this, a user with
|
||||
# workspace.skills permission could attach principal_id='*' read/write
|
||||
# grants in the create payload, bypassing the sharing.public_skills gate
|
||||
# that the dedicated /access/update endpoint already enforces.
|
||||
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_skills',
|
||||
)
|
||||
|
||||
try:
|
||||
skill = await Skills.insert_new_skill(user.id, form_data, db=db)
|
||||
if skill:
|
||||
@@ -276,6 +289,19 @@ async def update_skill_by_id(
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Strip public/user grants the requesting user is not permitted to assign
|
||||
# (matches the channel/notes/calendar pattern). The access check above only
|
||||
# restricts WHO can write to the skill; this filter restricts WHICH grants
|
||||
# they may set, so a non-admin owner cannot make their own skill publicly
|
||||
# readable/writable without sharing.public_skills permission.
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
request.app.state.config.USER_PERMISSIONS,
|
||||
user.id,
|
||||
user.role,
|
||||
form_data.access_grants,
|
||||
'sharing.public_skills',
|
||||
)
|
||||
|
||||
try:
|
||||
updated = {
|
||||
**form_data.model_dump(exclude={'id'}),
|
||||
|
||||
@@ -79,6 +79,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
|
||||
'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
|
||||
'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT,
|
||||
'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
|
||||
}
|
||||
|
||||
@@ -99,6 +100,7 @@ class TaskConfigForm(BaseModel):
|
||||
ENABLE_RETRIEVAL_QUERY_GENERATION: bool
|
||||
QUERY_GENERATION_PROMPT_TEMPLATE: str
|
||||
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str
|
||||
ENABLE_VOICE_MODE_PROMPT: bool
|
||||
VOICE_MODE_PROMPT_TEMPLATE: Optional[str]
|
||||
|
||||
|
||||
@@ -127,6 +129,7 @@ async def update_task_config(request: Request, form_data: TaskConfigForm, user=D
|
||||
request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE = form_data.QUERY_GENERATION_PROMPT_TEMPLATE
|
||||
request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
|
||||
|
||||
request.app.state.config.ENABLE_VOICE_MODE_PROMPT = form_data.ENABLE_VOICE_MODE_PROMPT
|
||||
request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = form_data.VOICE_MODE_PROMPT_TEMPLATE
|
||||
|
||||
return {
|
||||
@@ -145,6 +148,7 @@ async def update_task_config(request: Request, form_data: TaskConfigForm, user=D
|
||||
'ENABLE_RETRIEVAL_QUERY_GENERATION': request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
|
||||
'QUERY_GENERATION_PROMPT_TEMPLATE': request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
'TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE': request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
'ENABLE_VOICE_MODE_PROMPT': request.app.state.config.ENABLE_VOICE_MODE_PROMPT,
|
||||
'VOICE_MODE_PROMPT_TEMPLATE': request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
|
||||
}
|
||||
|
||||
@@ -159,6 +163,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
@@ -187,7 +192,7 @@ async def generate_title(request: Request, form_data: dict, user=Depends(get_ver
|
||||
else:
|
||||
template = DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = title_generation_template(template, form_data['messages'], user)
|
||||
content = await title_generation_template(template, form_data['messages'], user)
|
||||
|
||||
max_tokens = models[task_model_id].get('info', {}).get('params', {}).get('max_tokens', 1000)
|
||||
|
||||
@@ -236,6 +241,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
@@ -264,7 +270,7 @@ async def generate_follow_ups(request: Request, form_data: dict, user=Depends(ge
|
||||
else:
|
||||
template = DEFAULT_FOLLOW_UP_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = follow_up_generation_template(template, form_data['messages'], user)
|
||||
content = await follow_up_generation_template(template, form_data['messages'], user)
|
||||
|
||||
payload = {
|
||||
'model': task_model_id,
|
||||
@@ -304,6 +310,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
@@ -332,7 +339,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
|
||||
else:
|
||||
template = DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = tags_generation_template(template, form_data['messages'], user)
|
||||
content = await tags_generation_template(template, form_data['messages'], user)
|
||||
|
||||
payload = {
|
||||
'model': task_model_id,
|
||||
@@ -366,6 +373,7 @@ async def generate_chat_tags(request: Request, form_data: dict, user=Depends(get
|
||||
async def generate_image_prompt(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
@@ -394,7 +402,7 @@ async def generate_image_prompt(request: Request, form_data: dict, user=Depends(
|
||||
else:
|
||||
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = image_prompt_generation_template(template, form_data['messages'], user)
|
||||
content = await image_prompt_generation_template(template, form_data['messages'], user)
|
||||
|
||||
payload = {
|
||||
'model': task_model_id,
|
||||
@@ -446,6 +454,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
@@ -474,7 +483,7 @@ async def generate_queries(request: Request, form_data: dict, user=Depends(get_v
|
||||
else:
|
||||
template = DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = query_generation_template(template, form_data['messages'], user)
|
||||
content = await query_generation_template(template, form_data['messages'], user)
|
||||
|
||||
payload = {
|
||||
'model': task_model_id,
|
||||
@@ -524,6 +533,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
@@ -552,7 +562,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
|
||||
else:
|
||||
template = DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = autocomplete_generation_template(template, prompt, messages, type, user)
|
||||
content = await autocomplete_generation_template(template, prompt, messages, type, user)
|
||||
|
||||
payload = {
|
||||
'model': task_model_id,
|
||||
@@ -586,6 +596,7 @@ async def generate_autocompletion(request: Request, form_data: dict, user=Depend
|
||||
async def generate_emoji(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
@@ -611,7 +622,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver
|
||||
|
||||
template = DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = emoji_generation_template(template, form_data['prompt'], user)
|
||||
content = await emoji_generation_template(template, form_data['prompt'], user)
|
||||
|
||||
payload = {
|
||||
'model': task_model_id,
|
||||
@@ -651,6 +662,7 @@ async def generate_emoji(request: Request, form_data: dict, user=Depends(get_ver
|
||||
async def generate_moa_response(request: Request, form_data: dict, user=Depends(get_verified_user)):
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
|
||||
@@ -17,6 +17,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.config import TERMINAL_PROXY_HEADERS
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.users import Users
|
||||
|
||||
@@ -151,6 +152,8 @@ async def proxy_terminal(
|
||||
for key, value in upstream_response.headers.items()
|
||||
if key.lower() not in STRIPPED_RESPONSE_HEADERS
|
||||
}
|
||||
if TERMINAL_PROXY_HEADERS:
|
||||
filtered_headers.update(TERMINAL_PROXY_HEADERS)
|
||||
|
||||
# Stream binary responses directly
|
||||
if any(t in upstream_content_type for t in STREAMING_CONTENT_TYPES):
|
||||
|
||||
@@ -480,6 +480,17 @@ async def update_tools_by_id(
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Content edits trigger exec on load — gate them behind workspace.tools (matches /create).
|
||||
if form_data.content != tools.content:
|
||||
if user.role != 'admin' and not (
|
||||
await has_permission(user.id, 'workspace.tools', request.app.state.config.USER_PERMISSIONS, db=db)
|
||||
or await has_permission(user.id, 'workspace.tools_import', request.app.state.config.USER_PERMISSIONS, db=db)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
try:
|
||||
form_data.content = replace_imports(form_data.content)
|
||||
tool_module, frontmatter = await load_tool_module_by_id(id, content=form_data.content)
|
||||
|
||||
@@ -29,7 +29,7 @@ from open_webui.models.users import (
|
||||
)
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import STATIC_DIR
|
||||
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
|
||||
from open_webui.internal.db import get_async_session
|
||||
|
||||
|
||||
@@ -193,6 +193,8 @@ class SharingPermissions(BaseModel):
|
||||
public_skills: bool = False
|
||||
notes: bool = False
|
||||
public_notes: bool = True
|
||||
public_chats: bool = False
|
||||
public_calendars: bool = False
|
||||
|
||||
|
||||
class AccessGrantsPermissions(BaseModel):
|
||||
@@ -234,6 +236,7 @@ class FeaturesPermissions(BaseModel):
|
||||
code_interpreter: bool = True
|
||||
memories: bool = True
|
||||
automations: bool = False
|
||||
calendar: bool = True
|
||||
|
||||
|
||||
class SettingsPermissions(BaseModel):
|
||||
@@ -477,23 +480,32 @@ async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_u
|
||||
user = await Users.get_user_by_id(user_id)
|
||||
if user:
|
||||
if user.profile_image_url:
|
||||
# check if it's url or base64
|
||||
if user.profile_image_url.startswith('http'):
|
||||
return Response(
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
headers={'Location': user.profile_image_url},
|
||||
)
|
||||
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
|
||||
return Response(
|
||||
status_code=status.HTTP_302_FOUND,
|
||||
headers={'Location': user.profile_image_url},
|
||||
)
|
||||
# When forwarding is disabled, fall through to the
|
||||
# default image to prevent client-side IP/UA/Referer
|
||||
# leaks via 302 redirect to external origins.
|
||||
elif user.profile_image_url.startswith('data:image'):
|
||||
try:
|
||||
header, base64_data = user.profile_image_url.split(',', 1)
|
||||
image_data = base64.b64decode(base64_data)
|
||||
image_buffer = io.BytesIO(image_data)
|
||||
media_type = header.split(';')[0].lstrip('data:')
|
||||
media_type = header.split(';')[0].lstrip('data:').lower()
|
||||
|
||||
if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
|
||||
return FileResponse(f'{STATIC_DIR}/user.png')
|
||||
|
||||
return StreamingResponse(
|
||||
image_buffer,
|
||||
media_type=media_type,
|
||||
headers={'Content-Disposition': 'inline'},
|
||||
headers={
|
||||
'Content-Disposition': 'inline',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
@@ -832,7 +832,76 @@ async def disconnect(sid):
|
||||
# print(f"Unknown session ID {sid} disconnected")
|
||||
|
||||
|
||||
async def _make_channel_emitter(request_info):
|
||||
"""Event emitter that routes pipeline output to a channel message.
|
||||
|
||||
Translates chat:completion events into channel message:update socket
|
||||
emissions, throttled to avoid flooding with per-token updates.
|
||||
"""
|
||||
channel_id = request_info['chat_id'].removeprefix('channel:')
|
||||
message_id = request_info['message_id']
|
||||
|
||||
state = {'last_emit_at': 0.0}
|
||||
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
|
||||
|
||||
async def _emit_channel_update(content: str, done: bool = False):
|
||||
from open_webui.models.messages import Messages, MessageForm
|
||||
|
||||
update_form = MessageForm(content=content)
|
||||
if done:
|
||||
# Merge done flag into existing meta (preserve model_id etc.)
|
||||
msg = await Messages.get_message_by_id(message_id)
|
||||
existing_meta = (msg.meta or {}) if msg else {}
|
||||
update_form = MessageForm(
|
||||
content=content,
|
||||
meta={**existing_meta, 'done': True},
|
||||
)
|
||||
|
||||
await Messages.update_message_by_id(message_id, update_form)
|
||||
message = await Messages.get_message_by_id(message_id)
|
||||
if message:
|
||||
await sio.emit(
|
||||
'events:channel',
|
||||
{
|
||||
'channel_id': channel_id,
|
||||
'message_id': message_id,
|
||||
'data': {
|
||||
'type': 'message:update',
|
||||
'data': message.model_dump(),
|
||||
},
|
||||
},
|
||||
to=f'channel:{channel_id}',
|
||||
)
|
||||
|
||||
async def __channel_emitter__(event_data):
|
||||
event_type = event_data.get('type')
|
||||
|
||||
if event_type == 'chat:completion':
|
||||
data = event_data.get('data', {})
|
||||
content = data.get('content', '')
|
||||
done = data.get('done', False)
|
||||
|
||||
if not content and not done:
|
||||
return
|
||||
|
||||
now = __import__('time').time()
|
||||
if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL:
|
||||
state['last_emit_at'] = now
|
||||
await _emit_channel_update(content, done)
|
||||
|
||||
elif event_type == 'chat:message:error':
|
||||
error = event_data.get('data', {}).get('error', {})
|
||||
error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error)
|
||||
await _emit_channel_update(f'Error: {error_content}', done=True)
|
||||
|
||||
return __channel_emitter__
|
||||
|
||||
|
||||
async def get_event_emitter(request_info, update_db=True):
|
||||
# Channel mode: route pipeline output to channel message updates
|
||||
if request_info.get('chat_id', '').startswith('channel:'):
|
||||
return await _make_channel_emitter(request_info)
|
||||
|
||||
async def __event_emitter__(event_data):
|
||||
user_id = request_info['user_id']
|
||||
chat_id = request_info['chat_id']
|
||||
@@ -888,13 +957,15 @@ async def get_event_emitter(request_info, update_db=True):
|
||||
)
|
||||
|
||||
elif event_type == 'embeds':
|
||||
message = await Chats.get_message_by_id_and_message_id(
|
||||
request_info['chat_id'],
|
||||
request_info['message_id'],
|
||||
)
|
||||
event_payload = event_data.get('data', {})
|
||||
embeds = event_payload.get('embeds', [])
|
||||
|
||||
embeds = event_data.get('data', {}).get('embeds', [])
|
||||
embeds.extend(message.get('embeds', []))
|
||||
if not event_payload.get('replace', False):
|
||||
message = await Chats.get_message_by_id_and_message_id(
|
||||
request_info['chat_id'],
|
||||
request_info['message_id'],
|
||||
)
|
||||
embeds.extend(message.get('embeds', []))
|
||||
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
request_info['chat_id'],
|
||||
@@ -948,17 +1019,27 @@ async def get_event_emitter(request_info, update_db=True):
|
||||
|
||||
async def get_event_call(request_info):
|
||||
async def __event_caller__(event_data):
|
||||
response = await sio.call(
|
||||
'events',
|
||||
{
|
||||
'chat_id': request_info.get('chat_id', None),
|
||||
'message_id': request_info.get('message_id', None),
|
||||
'data': event_data,
|
||||
},
|
||||
to=request_info['session_id'],
|
||||
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
||||
)
|
||||
return response
|
||||
session_id = request_info['session_id']
|
||||
|
||||
# Fast-fail if the client has disconnected.
|
||||
if session_id not in SESSION_POOL:
|
||||
log.warning(f'Event caller: session {session_id} no longer connected')
|
||||
return {'error': 'Client session disconnected.'}
|
||||
|
||||
try:
|
||||
return await sio.call(
|
||||
'events',
|
||||
{
|
||||
'chat_id': request_info.get('chat_id', None),
|
||||
'message_id': request_info.get('message_id', None),
|
||||
'data': event_data,
|
||||
},
|
||||
to=session_id,
|
||||
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
log.warning(f'Event caller timed out for session {session_id}')
|
||||
return {'error': 'Event call timed out. The browser tab may be inactive or closed.'}
|
||||
|
||||
if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
|
||||
return __event_caller__
|
||||
|
||||
@@ -471,9 +471,15 @@ async def execute_code(
|
||||
|
||||
# Parse the output - pyodide returns dict with stdout, stderr, result
|
||||
if isinstance(output, dict):
|
||||
stdout = output.get('stdout', '')
|
||||
stderr = output.get('stderr', '')
|
||||
result = output.get('result', '')
|
||||
# Handle error responses from event_caller (e.g. session disconnected, timeout)
|
||||
if output.get('error') and not output.get('stdout') and not output.get('result'):
|
||||
stderr = output['error']
|
||||
stdout = ''
|
||||
result = ''
|
||||
else:
|
||||
stdout = output.get('stdout', '')
|
||||
stderr = output.get('stderr', '')
|
||||
result = output.get('result', '')
|
||||
else:
|
||||
stdout = ''
|
||||
stderr = ''
|
||||
@@ -2678,7 +2684,7 @@ async def update_automation(
|
||||
is_active=automation.is_active,
|
||||
)
|
||||
|
||||
updated = await Automations.update(automation_id, form, next_run_ns(new_rrule, tz=tz))
|
||||
updated = await Automations.update_by_id(automation_id, form, next_run_ns(new_rrule, tz=tz))
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
@@ -2922,8 +2928,9 @@ async def search_calendar_events(
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Search calendar events by text and/or date range.
|
||||
Returns matching events across all accessible calendars.
|
||||
Search calendar events, reminders, and scheduled items by text and/or date range.
|
||||
Use this to check what's coming up, find a specific event or reminder, or list
|
||||
the user's schedule for a time period.
|
||||
|
||||
: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)
|
||||
@@ -3019,17 +3026,19 @@ async def create_calendar_event(
|
||||
__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.
|
||||
Create a calendar event, reminder, or alarm. Use this when the user wants to
|
||||
schedule an event, set a reminder, create an alarm, or says things like
|
||||
"remind me", "don't let me forget", "notify me at", or "add to my calendar".
|
||||
For simple reminders, omit end/location/all_day and set reminder_minutes to 0.
|
||||
|
||||
: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 title: Event or reminder title (e.g. "Team standup", "Take medicine", "Call mom")
|
||||
:param start: Start datetime in the user's local time (e.g. "2026-04-20 09:00")
|
||||
:param end: End datetime in the user's local time (optional — omit for reminders or point-in-time events)
|
||||
:param description: Event description or notes (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)
|
||||
:param reminder_minutes: Minutes before the event to send a reminder notification (optional, default: 10). Use 0 for "at time of event", -1 for no reminder. Accepts any positive integer for custom timing (e.g. 120 for 2 hours before).
|
||||
:param reminder_minutes: Minutes before the event to send a notification (optional, default: 10). Use 0 for "at time of event", -1 for no notification.
|
||||
:return: JSON with the created event details including id
|
||||
"""
|
||||
if __request__ is None:
|
||||
@@ -3177,8 +3186,10 @@ async def update_calendar_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':
|
||||
if event.user_id != user_id and __user__.get('role') != 'admin':
|
||||
cal = await Calendars.get_calendar_by_id(event.calendar_id)
|
||||
if not cal:
|
||||
return json.dumps({'error': 'Access denied'})
|
||||
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,
|
||||
@@ -3278,8 +3289,10 @@ async def delete_calendar_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':
|
||||
if event.user_id != user_id and __user__.get('role') != 'admin':
|
||||
cal = await Calendars.get_calendar_by_id(event.calendar_id)
|
||||
if not cal:
|
||||
return json.dumps({'error': 'Access denied'})
|
||||
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,
|
||||
|
||||
@@ -87,3 +87,38 @@ async def has_access_to_file(
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def get_accessible_folder_files(
|
||||
entries: list[dict] | None,
|
||||
user: UserModel,
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[dict]:
|
||||
"""Filter folder.data['files'] entries to those the caller can read.
|
||||
|
||||
Each entry is expected to have 'type' ('file' or 'collection') and 'id'.
|
||||
Admins bypass all checks. Unknown types are kept as-is.
|
||||
"""
|
||||
if not entries:
|
||||
return []
|
||||
if user.role == 'admin':
|
||||
return list(entries)
|
||||
|
||||
accessible: list[dict] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_type = entry.get('type')
|
||||
entry_id = entry.get('id')
|
||||
if not entry_id:
|
||||
accessible.append(entry)
|
||||
continue
|
||||
if entry_type == 'file':
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db):
|
||||
accessible.append(entry)
|
||||
elif entry_type == 'collection':
|
||||
if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db):
|
||||
accessible.append(entry)
|
||||
else:
|
||||
accessible.append(entry)
|
||||
return accessible
|
||||
|
||||
@@ -88,6 +88,13 @@ class CommitSessionMiddleware:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get('path', '')
|
||||
# Keep health probes independent from sync session commit/remove
|
||||
# so DB pressure cannot delay or fail probe responses.
|
||||
if path in {'/health', '/ready', '/health/db'}:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
except BaseException:
|
||||
|
||||
@@ -360,7 +360,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
|
||||
await _record_run(automation.id, 'error', error='User not found')
|
||||
return
|
||||
|
||||
prompt = prompt_template(automation.data['prompt'], user)
|
||||
prompt = await prompt_template(automation.data['prompt'], user)
|
||||
model_id = automation.data['model_id']
|
||||
terminal_config = automation.data.get('terminal')
|
||||
|
||||
|
||||
@@ -73,6 +73,11 @@ async def generate_direct_chat_completion(
|
||||
request_id = str(uuid.uuid4()) # Generate a unique request ID
|
||||
|
||||
event_caller = await get_event_call(metadata)
|
||||
if event_caller is None:
|
||||
raise Exception(
|
||||
'Direct connection requires an active WebSocket session; '
|
||||
'cannot generate completion in this context (e.g. background task).'
|
||||
)
|
||||
|
||||
channel = f'{user_id}:{session_id}:{request_id}'
|
||||
logging.info(f'WebSocket channel: {channel}')
|
||||
@@ -180,10 +185,14 @@ async def generate_chat_completion(
|
||||
}
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
# Merge the direct connection model into server models so that
|
||||
# task functions (title, tags, etc.) can resolve a server-side
|
||||
# task model while still having the direct model available.
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
log.debug(f'direct connection to model: {models}')
|
||||
log.debug(f'direct connection to model: {request.state.model["id"]}')
|
||||
else:
|
||||
models = request.app.state.MODELS
|
||||
|
||||
@@ -193,7 +202,7 @@ async def generate_chat_completion(
|
||||
|
||||
model = models[model_id]
|
||||
|
||||
if getattr(request.state, 'direct', False):
|
||||
if getattr(request.state, 'direct', False) and model_id == getattr(request.state, 'model', {}).get('id'):
|
||||
return await generate_direct_chat_completion(request, form_data, user=user, models=models)
|
||||
else:
|
||||
# Check if user has access to the model
|
||||
@@ -310,6 +319,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
|
||||
|
||||
if getattr(request.state, 'direct', False) and hasattr(request.state, 'model'):
|
||||
models = {
|
||||
**request.app.state.MODELS,
|
||||
request.state.model['id']: request.state.model,
|
||||
}
|
||||
else:
|
||||
|
||||
@@ -8,6 +8,8 @@ import aiohttp
|
||||
import websockets
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -88,7 +90,7 @@ class JupyterCodeExecuter:
|
||||
async with self.session.post(
|
||||
'login',
|
||||
data={'_xsrf': xsrf_token, 'password': self.password},
|
||||
allow_redirects=False,
|
||||
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
self.session.cookie_jar.update_cookies(response.cookies)
|
||||
|
||||
@@ -26,7 +26,11 @@ import base64
|
||||
import io
|
||||
import re
|
||||
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
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)
|
||||
@@ -53,11 +57,17 @@ _IMAGE_MIME_FALLBACK = {
|
||||
async def get_image_base64_from_url(url: str) -> Optional[str]:
|
||||
try:
|
||||
if url.startswith('http'):
|
||||
# Validate URL to prevent SSRF attacks against local/private networks
|
||||
# Validate URL to prevent SSRF attacks against local/private networks.
|
||||
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
|
||||
# called only on the originally-submitted URL; following 3xx redirects
|
||||
# without re-validation would let an attacker reach private IPs via a
|
||||
# public host that redirects internally (e.g. cloud-metadata exfil).
|
||||
validate_url(url)
|
||||
# Download the image from the URL
|
||||
session = await get_session()
|
||||
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as response:
|
||||
async with session.get(
|
||||
url, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
image_data = await response.read()
|
||||
encoded_string = base64.b64encode(image_data).decode('utf-8')
|
||||
|
||||
@@ -16,3 +16,26 @@ def include_user_info_headers(headers, user):
|
||||
FORWARD_USER_INFO_HEADER_USER_EMAIL: user.email,
|
||||
FORWARD_USER_INFO_HEADER_USER_ROLE: user.role,
|
||||
}
|
||||
|
||||
|
||||
def get_custom_headers(custom_headers: dict, user=None, metadata: dict = None) -> dict:
|
||||
if not custom_headers or not isinstance(custom_headers, dict):
|
||||
return {}
|
||||
|
||||
metadata = metadata or {}
|
||||
template_vars = {
|
||||
'{{CHAT_ID}}': metadata.get('chat_id', '') or '',
|
||||
'{{MESSAGE_ID}}': metadata.get('message_id', '') or '',
|
||||
'{{USER_ID}}': (user.id if user else '') or '',
|
||||
'{{USER_NAME}}': (user.name if user else '') or '',
|
||||
}
|
||||
|
||||
parsed_headers = {}
|
||||
for key, value in custom_headers.items():
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
for token, val in template_vars.items():
|
||||
value = value.replace(token, val)
|
||||
parsed_headers[key] = value
|
||||
|
||||
return parsed_headers
|
||||
|
||||
@@ -106,6 +106,7 @@ from open_webui.utils.tools import (
|
||||
get_terminal_tools,
|
||||
)
|
||||
from open_webui.utils.access_control import has_connection_access
|
||||
from open_webui.utils.access_control.files import get_accessible_folder_files
|
||||
from open_webui.utils.plugin import load_function_module_by_id
|
||||
from open_webui.utils.filter import (
|
||||
get_sorted_filter_ids,
|
||||
@@ -969,7 +970,7 @@ def get_source_context(sources: list, source_ids: dict = None, include_content:
|
||||
return context_string
|
||||
|
||||
|
||||
def apply_source_context_to_messages(
|
||||
async def apply_source_context_to_messages(
|
||||
request: Request,
|
||||
messages: list,
|
||||
sources: list,
|
||||
@@ -995,13 +996,13 @@ def apply_source_context_to_messages(
|
||||
|
||||
if RAG_SYSTEM_CONTEXT:
|
||||
return add_or_update_system_message(
|
||||
rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
|
||||
await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
|
||||
messages,
|
||||
append=True,
|
||||
)
|
||||
else:
|
||||
return add_or_update_user_message(
|
||||
rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
|
||||
await rag_template(request.app.state.config.RAG_TEMPLATE, context, user_message),
|
||||
messages,
|
||||
append=False,
|
||||
)
|
||||
@@ -1707,7 +1708,7 @@ async def add_file_context(messages: list, chat_id: str, user) -> list:
|
||||
"""
|
||||
Add file URLs to messages for native function calling.
|
||||
"""
|
||||
if not chat_id or chat_id.startswith('local:'):
|
||||
if not chat_id or chat_id.startswith('local:') or chat_id.startswith('channel:'):
|
||||
return messages
|
||||
|
||||
chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id)
|
||||
@@ -1763,7 +1764,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
|
||||
if not chat_id or not isinstance(chat_id, str) or not __event_emitter__:
|
||||
return form_data
|
||||
|
||||
if chat_id.startswith('local:'):
|
||||
if chat_id.startswith('local:') or chat_id.startswith('channel:'):
|
||||
message_list = form_data.get('messages', [])
|
||||
else:
|
||||
chat = await Chats.get_chat_by_id_and_user_id(chat_id, user.id)
|
||||
@@ -2165,7 +2166,27 @@ async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[
|
||||
return [{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files')} for msg in db_messages]
|
||||
|
||||
|
||||
def process_messages_with_output(messages: list[dict]) -> list[dict]:
|
||||
def get_reasoning_format(model: dict) -> str | None:
|
||||
"""
|
||||
Determine how reasoning should be included in reconstructed messages.
|
||||
|
||||
Returns:
|
||||
'think_tags': Ollama expects <think> tags in content.
|
||||
'reasoning_content': llama.cpp supports reasoning_content as a top-level field.
|
||||
None: skip reasoning (safe default for strict providers).
|
||||
"""
|
||||
provider = model.get('provider', '')
|
||||
if provider == 'ollama':
|
||||
return 'think_tags'
|
||||
if provider == 'llama.cpp':
|
||||
return 'reasoning_content'
|
||||
return None
|
||||
|
||||
|
||||
def process_messages_with_output(
|
||||
messages: list[dict],
|
||||
reasoning_format: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Process messages with OR-aligned output items for LLM consumption.
|
||||
|
||||
@@ -2177,7 +2198,11 @@ def process_messages_with_output(messages: list[dict]) -> list[dict]:
|
||||
for message in messages:
|
||||
if message.get('role') == 'assistant' and message.get('output'):
|
||||
# Use output items for clean OpenAI-format messages
|
||||
output_messages = convert_output_to_messages(message['output'], raw=True)
|
||||
output_messages = convert_output_to_messages(
|
||||
message['output'],
|
||||
raw=True,
|
||||
reasoning_format=reasoning_format,
|
||||
)
|
||||
if output_messages:
|
||||
processed.extend(output_messages)
|
||||
continue
|
||||
@@ -2263,14 +2288,27 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
form_data = apply_params_to_form_data(form_data, model)
|
||||
log.debug(f'form_data: {form_data}')
|
||||
|
||||
# Guided regeneration: extract before it reaches the LLM provider
|
||||
regeneration_prompt = form_data.pop('regeneration_prompt', None)
|
||||
|
||||
# Load messages from DB when available — DB preserves structured 'output' items
|
||||
# which the frontend strips, causing tool calls to be merged into content.
|
||||
chat_id = metadata.get('chat_id')
|
||||
user_message_id = metadata.get('user_message_id')
|
||||
|
||||
if chat_id and user_message_id and not chat_id.startswith('local:'):
|
||||
if chat_id and user_message_id and not chat_id.startswith('local:') and not chat_id.startswith('channel:'):
|
||||
db_messages = await load_messages_from_db(chat_id, user_message_id)
|
||||
if db_messages:
|
||||
# Continue: frontend sends assistant_message_id when continuing
|
||||
# an existing response. Load its content so the LLM sees prior output.
|
||||
assistant_message_id = metadata.get('assistant_message_id')
|
||||
if assistant_message_id:
|
||||
assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id)
|
||||
if assistant_message and (assistant_message.get('content') or assistant_message.get('output')):
|
||||
db_messages.append(
|
||||
{k: v for k, v in assistant_message.items() if k in ('role', 'content', 'output', 'files')}
|
||||
)
|
||||
|
||||
system_message = get_system_message(form_data.get('messages', []))
|
||||
form_data['messages'] = [system_message, *db_messages] if system_message else db_messages
|
||||
|
||||
@@ -2298,13 +2336,19 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
# Strip files field — it's been incorporated into content
|
||||
message.pop('files', None)
|
||||
|
||||
if regeneration_prompt:
|
||||
form_data['messages'].append({'role': 'user', 'content': regeneration_prompt})
|
||||
|
||||
# Process messages with OR-aligned output items for clean LLM messages
|
||||
form_data['messages'] = process_messages_with_output(form_data.get('messages', []))
|
||||
form_data['messages'] = process_messages_with_output(
|
||||
form_data.get('messages', []),
|
||||
reasoning_format=get_reasoning_format(model),
|
||||
)
|
||||
|
||||
system_message = get_system_message(form_data.get('messages', []))
|
||||
if system_message: # Chat Controls/User Settings
|
||||
try:
|
||||
form_data = apply_system_prompt_to_body(
|
||||
form_data = await apply_system_prompt_to_body(
|
||||
system_message.get('content'), form_data, metadata, user, replace=True
|
||||
) # Required to handle system prompt variables
|
||||
except Exception:
|
||||
@@ -2362,17 +2406,19 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
if folder and folder.data:
|
||||
if 'system_prompt' in folder.data:
|
||||
form_data = apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user)
|
||||
form_data = await apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user)
|
||||
if 'files' in folder.data:
|
||||
# Defensive: filter to entries the caller can still read.
|
||||
allowed_files = await get_accessible_folder_files(folder.data['files'], user)
|
||||
if metadata.get('params', {}).get('function_calling') != 'native':
|
||||
form_data['files'] = [
|
||||
*folder.data['files'],
|
||||
*allowed_files,
|
||||
*form_data.get('files', []),
|
||||
]
|
||||
else:
|
||||
# Native FC: skip RAG injection, builtin tools
|
||||
# will read folder knowledge from metadata.
|
||||
metadata['folder_knowledge'] = folder.data['files']
|
||||
metadata['folder_knowledge'] = allowed_files
|
||||
|
||||
# Model "Knowledge" handling
|
||||
user_message = get_last_user_message(form_data['messages'])
|
||||
@@ -2417,6 +2463,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
form_data['files'] = files
|
||||
|
||||
variables = form_data.pop('variables', None)
|
||||
payload_tools = form_data.get('tools', None) # snapshot before filters
|
||||
|
||||
# Process the form_data through the pipeline
|
||||
try:
|
||||
@@ -2442,8 +2489,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
extra_params['__features__'] = features
|
||||
if features:
|
||||
if 'voice' in features and features['voice']:
|
||||
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != None:
|
||||
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE != '':
|
||||
if getattr(request.app.state.config, 'ENABLE_VOICE_MODE_PROMPT', True):
|
||||
if request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE:
|
||||
template = request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE
|
||||
else:
|
||||
template = DEFAULT_VOICE_MODE_PROMPT_TEMPLATE
|
||||
@@ -2507,9 +2554,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
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).
|
||||
payload_tools = form_data.get('tools', None)
|
||||
# If the original caller provided tools, use them as-is (skip resolution).
|
||||
# Otherwise, save any tools that filter inlets added for merging later.
|
||||
inlet_filter_tools = None if payload_tools else form_data.get('tools', None)
|
||||
|
||||
# Skills — extract IDs from message content (<$skillId|label> tags) so
|
||||
# persisted chats work without relying on the frontend to send skill_ids.
|
||||
@@ -2571,7 +2618,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id)
|
||||
if folder and folder.data and 'files' in folder.data:
|
||||
files = [f for f in files if f.get('id', None) != folder_id]
|
||||
files = [*files, *folder.data['files']]
|
||||
files = [*files, *await get_accessible_folder_files(folder.data['files'], user)]
|
||||
|
||||
# files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]]
|
||||
# Remove duplicate files based on their content
|
||||
@@ -2818,6 +2865,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
form_data['tools'] = [
|
||||
{'type': 'function', 'function': tool.get('spec', {})} for tool in tools_dict.values()
|
||||
]
|
||||
if inlet_filter_tools:
|
||||
form_data['tools'].extend(inlet_filter_tools)
|
||||
else:
|
||||
# If the function calling is not native, then call the tools function calling handler
|
||||
try:
|
||||
@@ -2848,7 +2897,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
||||
|
||||
# If context is not empty, insert it into the messages
|
||||
if sources and prompt:
|
||||
form_data['messages'] = apply_source_context_to_messages(request, form_data['messages'], sources, prompt)
|
||||
form_data['messages'] = await apply_source_context_to_messages(request, form_data['messages'], sources, prompt)
|
||||
|
||||
# If there are citations, add them to the data_items
|
||||
sources = [
|
||||
@@ -3009,7 +3058,11 @@ async def background_tasks_handler(ctx):
|
||||
message = None
|
||||
messages = []
|
||||
|
||||
if 'chat_id' in metadata and not metadata['chat_id'].startswith('local:'):
|
||||
if (
|
||||
'chat_id' in metadata
|
||||
and not metadata['chat_id'].startswith('local:')
|
||||
and not metadata['chat_id'].startswith('channel:')
|
||||
):
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(metadata['chat_id'])
|
||||
message = messages_map.get(metadata['message_id']) if messages_map else None
|
||||
|
||||
@@ -3089,7 +3142,9 @@ async def background_tasks_handler(ctx):
|
||||
}
|
||||
)
|
||||
|
||||
if not metadata.get('chat_id', '').startswith('local:'):
|
||||
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get(
|
||||
'chat_id', ''
|
||||
).startswith('channel:'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
@@ -3101,7 +3156,9 @@ async def background_tasks_handler(ctx):
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
if not metadata.get('chat_id', '').startswith('local:'): # Only update titles and tags for non-temp chats
|
||||
if not metadata.get('chat_id', '').startswith('local:') and not metadata.get('chat_id', '').startswith(
|
||||
'channel:'
|
||||
): # Only update titles and tags for non-temp chats
|
||||
if TASKS.TITLE_GENERATION in tasks:
|
||||
user_message = get_last_user_message(messages)
|
||||
if user_message and len(user_message) > 100:
|
||||
@@ -3225,7 +3282,7 @@ async def outlet_filter_handler(ctx):
|
||||
if not chat_id or not message_id:
|
||||
return
|
||||
|
||||
is_temp_chat = chat_id.startswith('local:')
|
||||
is_temp_chat = chat_id.startswith('local:') or chat_id.startswith('channel:')
|
||||
|
||||
try:
|
||||
messages_map = None
|
||||
@@ -3367,13 +3424,14 @@ async def non_streaming_chat_response_handler(response, ctx):
|
||||
|
||||
log.error('Provider returned error (non-streaming): %s', error)
|
||||
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'error': {'content': error},
|
||||
},
|
||||
)
|
||||
if not metadata['chat_id'].startswith('channel:'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'error': {'content': error},
|
||||
},
|
||||
)
|
||||
if isinstance(error, str) or isinstance(error, dict):
|
||||
await event_emitter(
|
||||
{
|
||||
@@ -3382,7 +3440,7 @@ async def non_streaming_chat_response_handler(response, ctx):
|
||||
}
|
||||
)
|
||||
|
||||
if 'selected_model_id' in response_data:
|
||||
if 'selected_model_id' in response_data and not metadata['chat_id'].startswith('channel:'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
@@ -3403,7 +3461,11 @@ async def non_streaming_chat_response_handler(response, ctx):
|
||||
}
|
||||
)
|
||||
|
||||
title = await Chats.get_chat_title_by_id(metadata['chat_id'])
|
||||
title = (
|
||||
await Chats.get_chat_title_by_id(metadata['chat_id'])
|
||||
if not metadata['chat_id'].startswith('channel:')
|
||||
else ''
|
||||
)
|
||||
|
||||
# Use output from backend if provided (OR-compliant backends),
|
||||
# otherwise generate from response content
|
||||
@@ -3434,17 +3496,18 @@ async def non_streaming_chat_response_handler(response, ctx):
|
||||
# Save message in the database
|
||||
usage = normalize_usage(response_data.get('usage', {}) or {})
|
||||
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'done': True,
|
||||
'role': 'assistant',
|
||||
'content': content,
|
||||
'output': response_output,
|
||||
**({'usage': usage} if usage else {}),
|
||||
},
|
||||
)
|
||||
if not metadata['chat_id'].startswith('channel:'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'done': True,
|
||||
'role': 'assistant',
|
||||
'content': content,
|
||||
'output': response_output,
|
||||
**({'usage': usage} if usage else {}),
|
||||
},
|
||||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id):
|
||||
@@ -3945,6 +4008,12 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
response_id = response_metadata.pop('response_id', None)
|
||||
if response_id:
|
||||
last_response_id = response_id
|
||||
|
||||
# Normalize and capture usage for DB persistence
|
||||
if response_metadata.get('usage'):
|
||||
response_metadata['usage'] = normalize_usage(response_metadata['usage'])
|
||||
usage = response_metadata['usage']
|
||||
|
||||
processed_data.update(response_metadata)
|
||||
processed_data.pop('done', None)
|
||||
|
||||
@@ -4293,7 +4362,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
if end:
|
||||
break
|
||||
|
||||
if ENABLE_REALTIME_CHAT_SAVE:
|
||||
if ENABLE_REALTIME_CHAT_SAVE and not metadata['chat_id'].startswith('channel:'):
|
||||
# Save message in the database
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
@@ -4664,7 +4733,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
)
|
||||
source_context = source_context.strip()
|
||||
if source_context:
|
||||
rag_content = rag_template(
|
||||
rag_content = await rag_template(
|
||||
request.app.state.config.RAG_TEMPLATE,
|
||||
source_context,
|
||||
user_message,
|
||||
@@ -4716,10 +4785,14 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
system_message = get_system_message(form_data['messages'])
|
||||
new_form_data['messages'] = (
|
||||
[system_message] if system_message else []
|
||||
) + convert_output_to_messages(output, raw=True)
|
||||
) + convert_output_to_messages(
|
||||
output, raw=True, reasoning_format=get_reasoning_format(model)
|
||||
)
|
||||
new_form_data['previous_response_id'] = last_response_id
|
||||
else:
|
||||
tool_messages = convert_output_to_messages(output, raw=True)
|
||||
tool_messages = convert_output_to_messages(
|
||||
output, raw=True, reasoning_format=get_reasoning_format(model)
|
||||
)
|
||||
|
||||
# Chat Completions providers don't support multimodal
|
||||
# tool messages. Extract images into a user message.
|
||||
@@ -4816,8 +4889,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
code = sanitize_code(code)
|
||||
|
||||
if CODE_INTERPRETER_BLOCKED_MODULES:
|
||||
blocking_code = textwrap.dedent(
|
||||
f"""
|
||||
blocking_code = textwrap.dedent(f"""
|
||||
import builtins
|
||||
|
||||
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
|
||||
@@ -4833,8 +4905,7 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
return _real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
builtins.__import__ = restricted_import
|
||||
"""
|
||||
)
|
||||
""")
|
||||
code = blocking_code + '\n' + code
|
||||
|
||||
if request.app.state.config.CODE_INTERPRETER_ENGINE == 'pyodide':
|
||||
@@ -4870,6 +4941,11 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
|
||||
log.debug(f'Code interpreter output: {ci_output}')
|
||||
|
||||
# Handle error responses from event_caller
|
||||
# (e.g. session disconnected, timeout)
|
||||
if isinstance(ci_output, dict) and ci_output.get('error'):
|
||||
ci_output = {'stderr': ci_output['error']}
|
||||
|
||||
if isinstance(ci_output, dict):
|
||||
stdout = ci_output.get('stdout', '')
|
||||
|
||||
@@ -4936,7 +5012,9 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
'metadata': metadata,
|
||||
'messages': [
|
||||
*form_data['messages'],
|
||||
*convert_output_to_messages(output, raw=True),
|
||||
*convert_output_to_messages(
|
||||
output, raw=True, reasoning_format=get_reasoning_format(model)
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -4960,7 +5038,11 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
if item.get('status') == 'in_progress':
|
||||
item['status'] = 'completed'
|
||||
|
||||
title = await Chats.get_chat_title_by_id(metadata['chat_id'])
|
||||
title = (
|
||||
await Chats.get_chat_title_by_id(metadata['chat_id'])
|
||||
if not metadata['chat_id'].startswith('channel:')
|
||||
else ''
|
||||
)
|
||||
data = {
|
||||
'done': True,
|
||||
'content': serialize_output(output),
|
||||
@@ -4969,30 +5051,31 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
**({'usage': usage} if usage else {}),
|
||||
}
|
||||
|
||||
if not ENABLE_REALTIME_CHAT_SAVE:
|
||||
# Save message in the database
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'done': True,
|
||||
'content': serialize_output(output),
|
||||
'output': output,
|
||||
**({'usage': usage} if usage else {}),
|
||||
},
|
||||
)
|
||||
elif usage:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True, 'usage': usage},
|
||||
)
|
||||
else:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
)
|
||||
if not metadata['chat_id'].startswith('channel:'):
|
||||
if not ENABLE_REALTIME_CHAT_SAVE:
|
||||
# Save message in the database
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'done': True,
|
||||
'content': serialize_output(output),
|
||||
'output': output,
|
||||
**({'usage': usage} if usage else {}),
|
||||
},
|
||||
)
|
||||
elif usage:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True, 'usage': usage},
|
||||
)
|
||||
else:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if request.app.state.config.ENABLE_USER_WEBHOOKS and not await Users.is_user_active(user.id):
|
||||
@@ -5039,22 +5122,23 @@ async def streaming_chat_response_handler(response, ctx):
|
||||
|
||||
async def save_cancelled_state():
|
||||
await event_emitter({'type': 'chat:tasks:cancel'})
|
||||
if not ENABLE_REALTIME_CHAT_SAVE:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'done': True,
|
||||
'content': serialize_output(output),
|
||||
'output': output,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
)
|
||||
if not metadata['chat_id'].startswith('channel:'):
|
||||
if not ENABLE_REALTIME_CHAT_SAVE:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'done': True,
|
||||
'content': serialize_output(output),
|
||||
'output': output,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.shield(save_cancelled_state())
|
||||
|
||||
@@ -129,7 +129,11 @@ def get_content_from_message(message: dict) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
||||
def convert_output_to_messages(
|
||||
output: list,
|
||||
raw: bool = False,
|
||||
reasoning_format: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Convert OR-aligned output items to OpenAI Chat Completion-format messages.
|
||||
|
||||
@@ -139,8 +143,14 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
||||
|
||||
Args:
|
||||
output: List of OR-aligned output items (Responses API format).
|
||||
raw: If True, include reasoning blocks (with original tags) and code
|
||||
interpreter blocks for LLM re-processing follow-ups.
|
||||
raw: If True, include code interpreter blocks for LLM re-processing
|
||||
follow-ups.
|
||||
reasoning_format: How to include reasoning blocks in the output:
|
||||
- None: skip reasoning (default, safe for strict providers).
|
||||
- ``'think_tags'``: wrap in ``<think>`` tags inside content
|
||||
(for Ollama, which expects reasoning as tagged content).
|
||||
- ``'reasoning_content'``: set as ``reasoning_content`` top-level field
|
||||
(for llama.cpp, which routes it via the chat template).
|
||||
"""
|
||||
if not output or not isinstance(output, list):
|
||||
return []
|
||||
@@ -148,19 +158,26 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
||||
messages = []
|
||||
pending_tool_calls = []
|
||||
pending_content = []
|
||||
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
|
||||
|
||||
def flush_pending():
|
||||
nonlocal pending_content, pending_tool_calls
|
||||
if pending_content or pending_tool_calls:
|
||||
messages.append(
|
||||
{
|
||||
'role': 'assistant',
|
||||
'content': '\n'.join(pending_content) if pending_content else '',
|
||||
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
|
||||
}
|
||||
)
|
||||
pending_content = []
|
||||
pending_tool_calls = []
|
||||
nonlocal pending_content, pending_tool_calls, pending_reasoning
|
||||
if not pending_content and not pending_tool_calls and not pending_reasoning:
|
||||
return
|
||||
|
||||
message = {
|
||||
'role': 'assistant',
|
||||
'content': '\n'.join(pending_content) if pending_content else '',
|
||||
**({'tool_calls': pending_tool_calls} if pending_tool_calls else {}),
|
||||
}
|
||||
|
||||
if pending_reasoning:
|
||||
message['reasoning_content'] = '\n'.join(pending_reasoning)
|
||||
|
||||
messages.append(message)
|
||||
pending_content = []
|
||||
pending_tool_calls = []
|
||||
pending_reasoning = []
|
||||
|
||||
for item in output:
|
||||
item_type = item.get('type', '')
|
||||
@@ -231,27 +248,26 @@ def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]:
|
||||
)
|
||||
|
||||
elif item_type == 'reasoning':
|
||||
if raw:
|
||||
# Include reasoning with original tags for LLM re-processing
|
||||
reasoning_text = ''
|
||||
source_list = item.get('summary', []) or item.get('content', [])
|
||||
for part in source_list:
|
||||
if part.get('type') == 'output_text':
|
||||
reasoning_text += part.get('text', '')
|
||||
elif 'text' in part:
|
||||
reasoning_text += part.get('text', '')
|
||||
if not reasoning_format:
|
||||
continue
|
||||
|
||||
if reasoning_text:
|
||||
reasoning_text = ''
|
||||
source_list = item.get('summary', []) or item.get('content', [])
|
||||
for part in source_list:
|
||||
if part.get('type') == 'output_text':
|
||||
reasoning_text += part.get('text', '')
|
||||
elif 'text' in part:
|
||||
reasoning_text += part.get('text', '')
|
||||
|
||||
if reasoning_text:
|
||||
if reasoning_format == 'think_tags':
|
||||
# Ollama: embed in content with the item's original tags
|
||||
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 reasoning_format == 'reasoning_content':
|
||||
# llama.cpp: collect for reasoning_content field
|
||||
pending_reasoning.append(reasoning_text)
|
||||
|
||||
elif item_type == 'open_webui:code_interpreter':
|
||||
# Always include code interpreter content so the LLM knows
|
||||
|
||||
@@ -47,6 +47,7 @@ async def fetch_ollama_models(request: Request, user: UserModel = None):
|
||||
'created': int(time.time()),
|
||||
'owned_by': 'ollama',
|
||||
'ollama': model,
|
||||
'loaded': 'expires_at' in model,
|
||||
'connection_type': model.get('connection_type', 'local'),
|
||||
'tags': model.get('tags', []),
|
||||
}
|
||||
@@ -199,6 +200,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
||||
'connection_type': connection_type,
|
||||
'preset': True,
|
||||
**({'pipe': pipe} if pipe is not None else {}),
|
||||
**({'provider': base_model.get('provider')} if base_model and base_model.get('provider') else {}),
|
||||
**({'loaded': base_model.get('loaded')} if base_model and base_model.get('loaded') is not None else {}),
|
||||
}
|
||||
|
||||
info = custom_model.model_dump()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
from dataclasses import dataclass, field
|
||||
import copy
|
||||
import hashlib
|
||||
import logging
|
||||
@@ -37,6 +38,7 @@ from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupF
|
||||
from open_webui.config import (
|
||||
DEFAULT_USER_ROLE,
|
||||
ENABLE_OAUTH_SIGNUP,
|
||||
OAUTH_CLIENT_TIMEOUT,
|
||||
OAUTH_REFRESH_TOKEN_INCLUDE_SCOPE,
|
||||
OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
|
||||
OAUTH_PROVIDERS,
|
||||
@@ -69,6 +71,7 @@ from open_webui.config import (
|
||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||
from open_webui.env import (
|
||||
AIOHTTP_CLIENT_SESSION_SSL,
|
||||
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
WEBUI_NAME,
|
||||
WEBUI_AUTH_COOKIE_SAME_SITE,
|
||||
WEBUI_AUTH_COOKIE_SECURE,
|
||||
@@ -99,6 +102,7 @@ class OAuthClientMetadata(MCPOAuthClientMetadata):
|
||||
|
||||
class OAuthClientInformationFull(OAuthClientMetadata):
|
||||
issuer: Optional[str] = None # URL of the OAuth server that issued this client
|
||||
resource: Optional[str] = None # RFC 8707 resource indicator for JWT audience
|
||||
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
@@ -289,12 +293,34 @@ def get_parsed_and_base_url(server_url) -> tuple[urllib.parse.ParseResult, str]:
|
||||
return parsed, base_url
|
||||
|
||||
|
||||
async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
|
||||
"""
|
||||
https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
|
||||
"""
|
||||
@dataclass
|
||||
class ProtectedResourceMetadata:
|
||||
"""RFC 9728 Protected Resource Metadata fields relevant to OAuth flows."""
|
||||
|
||||
resource: str | None = None
|
||||
authorization_servers: list[str] = field(default_factory=list)
|
||||
|
||||
def get_discovery_urls(self, server_url: str) -> list[str]:
|
||||
"""Build all candidate OAuth discovery URLs from this metadata and the server URL."""
|
||||
urls = []
|
||||
for auth_server in self.authorization_servers:
|
||||
urls.extend(_build_well_known_urls(auth_server.rstrip('/')))
|
||||
urls.extend(_build_well_known_urls(server_url))
|
||||
return urls
|
||||
|
||||
|
||||
async def get_protected_resource_metadata(server_url: str) -> ProtectedResourceMetadata:
|
||||
"""
|
||||
Fetch RFC 9728 Protected Resource Metadata from an MCP server.
|
||||
|
||||
https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization
|
||||
|
||||
Returns:
|
||||
ProtectedResourceMetadata with the resource indicator (RFC 8707)
|
||||
and authorization server URLs discovered from the metadata document.
|
||||
"""
|
||||
authorization_servers = []
|
||||
resource = None
|
||||
try:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
@@ -334,6 +360,10 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
|
||||
if resource_response.status == 200:
|
||||
resource_metadata = await resource_response.json()
|
||||
|
||||
resource = resource_metadata.get('resource') or None
|
||||
if resource:
|
||||
log.debug(f'Discovered resource indicator: {resource}')
|
||||
|
||||
servers = resource_metadata.get('authorization_servers', [])
|
||||
if servers:
|
||||
authorization_servers = servers
|
||||
@@ -345,12 +375,7 @@ async def get_authorization_server_discovery_urls(server_url: str) -> list[str]:
|
||||
except Exception as e:
|
||||
log.debug(f'MCP Protected Resource discovery failed: {e}')
|
||||
|
||||
discovery_urls = []
|
||||
for auth_server in authorization_servers:
|
||||
auth_server = auth_server.rstrip('/')
|
||||
discovery_urls.extend(_build_well_known_urls(auth_server))
|
||||
|
||||
return discovery_urls
|
||||
return ProtectedResourceMetadata(resource=resource, authorization_servers=authorization_servers)
|
||||
|
||||
|
||||
def _build_well_known_urls(server_url: str) -> list[str]:
|
||||
@@ -379,9 +404,9 @@ def _build_well_known_urls(server_url: str) -> list[str]:
|
||||
|
||||
|
||||
async def get_discovery_urls(server_url) -> list[str]:
|
||||
urls = await get_authorization_server_discovery_urls(server_url)
|
||||
urls.extend(_build_well_known_urls(server_url))
|
||||
return urls
|
||||
"""Convenience: get all OAuth discovery URLs for a server URL."""
|
||||
metadata = await get_protected_resource_metadata(server_url)
|
||||
return metadata.get_discovery_urls(server_url)
|
||||
|
||||
|
||||
# TODO: Some OAuth providers require Initial Access Tokens (IATs) for dynamic client registration.
|
||||
@@ -406,7 +431,9 @@ async def get_oauth_client_info_with_dynamic_client_registration(
|
||||
)
|
||||
|
||||
# Attempt to fetch OAuth server metadata to get registration endpoint & scopes
|
||||
discovery_urls = await get_discovery_urls(oauth_server_url)
|
||||
resource_metadata = await get_protected_resource_metadata(oauth_server_url)
|
||||
resource = resource_metadata.resource
|
||||
discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url)
|
||||
for url in discovery_urls:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as oauth_server_metadata_response:
|
||||
@@ -466,8 +493,9 @@ async def get_oauth_client_info_with_dynamic_client_registration(
|
||||
oauth_client_info = OAuthClientInformationFull.model_validate(
|
||||
{
|
||||
**registration_response_json,
|
||||
**{'issuer': oauth_server_metadata_url},
|
||||
**{'server_metadata': oauth_server_metadata},
|
||||
'issuer': oauth_server_metadata_url,
|
||||
'server_metadata': oauth_server_metadata,
|
||||
'resource': resource,
|
||||
}
|
||||
)
|
||||
log.info(
|
||||
@@ -516,7 +544,9 @@ async def get_oauth_client_info_with_static_credentials(
|
||||
redirect_uri = f'{redirect_base_url}/oauth/clients/{client_id}/callback'
|
||||
|
||||
# Discover server metadata (authorization endpoint, token endpoint, scopes, etc.)
|
||||
discovery_urls = await get_discovery_urls(oauth_server_url)
|
||||
resource_metadata = await get_protected_resource_metadata(oauth_server_url)
|
||||
resource = resource_metadata.resource
|
||||
discovery_urls = resource_metadata.get_discovery_urls(oauth_server_url)
|
||||
for url in discovery_urls:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
|
||||
@@ -555,6 +585,7 @@ async def get_oauth_client_info_with_static_credentials(
|
||||
token_endpoint_auth_method=token_endpoint_auth_method,
|
||||
issuer=oauth_server_metadata_url,
|
||||
server_metadata=oauth_server_metadata,
|
||||
resource=resource,
|
||||
)
|
||||
|
||||
log.info(
|
||||
@@ -597,6 +628,7 @@ class OAuthClientManager:
|
||||
'client_secret': oauth_client_info.client_secret,
|
||||
'client_kwargs': {
|
||||
'follow_redirects': True,
|
||||
**({'timeout': int(OAUTH_CLIENT_TIMEOUT.value)} if OAUTH_CLIENT_TIMEOUT.value else {}),
|
||||
**({'scope': oauth_client_info.scope} if oauth_client_info.scope else {}),
|
||||
**(
|
||||
{'token_endpoint_auth_method': oauth_client_info.token_endpoint_auth_method}
|
||||
@@ -709,7 +741,7 @@ class OAuthClientManager:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(
|
||||
authorization_url,
|
||||
allow_redirects=False,
|
||||
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as resp:
|
||||
if resp.status < 400:
|
||||
@@ -872,6 +904,11 @@ class OAuthClientManager:
|
||||
'refresh_token': token_data['refresh_token'],
|
||||
'client_id': client.client_id,
|
||||
}
|
||||
# RFC 8707: include resource indicator so refreshed tokens retain correct audience
|
||||
client_info = self.get_client_info(client_id)
|
||||
if client_info and client_info.resource:
|
||||
refresh_data['resource'] = client_info.resource
|
||||
|
||||
if hasattr(client, 'client_secret') and client.client_secret:
|
||||
refresh_data['client_secret'] = client.client_secret
|
||||
|
||||
@@ -924,7 +961,11 @@ class OAuthClientManager:
|
||||
|
||||
redirect_uri = client_info.redirect_uris[0] if client_info.redirect_uris else None
|
||||
redirect_uri_str = str(redirect_uri) if redirect_uri else None
|
||||
return await client.authorize_redirect(request, redirect_uri_str)
|
||||
# RFC 8707: pass resource indicator so the IdP sets the correct JWT audience
|
||||
kwargs = {}
|
||||
if client_info.resource:
|
||||
kwargs['resource'] = client_info.resource
|
||||
return await client.authorize_redirect(request, redirect_uri_str, **kwargs)
|
||||
|
||||
async def handle_callback(self, request, client_id: str, user_id: str, response):
|
||||
client = self.get_client(client_id) or self.ensure_client_from_config(client_id)
|
||||
@@ -939,7 +980,11 @@ class OAuthClientManager:
|
||||
# The Authlib client already has these configured during add_client().
|
||||
# Passing them again causes Authlib to concatenate them (e.g., "ID1,ID1"),
|
||||
# which results in 401 errors from the token endpoint. (Fix for #19823)
|
||||
token = await client.authorize_access_token(request)
|
||||
# RFC 8707: pass resource indicator for correct JWT audience on token exchange
|
||||
token_kwargs = {}
|
||||
if client_info and client_info.resource:
|
||||
token_kwargs['resource'] = client_info.resource
|
||||
token = await client.authorize_access_token(request, **token_kwargs)
|
||||
|
||||
# Validate that we received a proper token response
|
||||
# If token exchange failed (e.g., 401), we may get an error response instead
|
||||
|
||||
@@ -13,7 +13,7 @@ import json
|
||||
# What goes out cannot be taken back. Let it be shaped
|
||||
# well before it leaves this place.
|
||||
# inplace function: form_data is modified
|
||||
def apply_system_prompt_to_body(
|
||||
async def apply_system_prompt_to_body(
|
||||
system: Optional[str],
|
||||
form_data: dict,
|
||||
metadata: Optional[dict] = None,
|
||||
@@ -30,7 +30,7 @@ def apply_system_prompt_to_body(
|
||||
system = prompt_variables_template(system, variables)
|
||||
|
||||
# Legacy (API Usage)
|
||||
system = prompt_template(system, user)
|
||||
system = await prompt_template(system, user)
|
||||
|
||||
if replace:
|
||||
form_data['messages'] = replace_system_message_content(system, form_data.get('messages', []))
|
||||
|
||||
@@ -383,7 +383,11 @@ async def get_function_module_from_cache(
|
||||
return function_module, function_type, frontmatter
|
||||
|
||||
|
||||
_installed_requirements = set()
|
||||
|
||||
|
||||
def install_frontmatter_requirements(requirements: str):
|
||||
global _installed_requirements
|
||||
if not ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS:
|
||||
log.info('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS is disabled, skipping installation of requirements.')
|
||||
return
|
||||
@@ -395,12 +399,18 @@ def install_frontmatter_requirements(requirements: str):
|
||||
if requirements:
|
||||
try:
|
||||
req_list = [req.strip() for req in requirements.split(',')]
|
||||
log.info(f'Installing requirements: {" ".join(req_list)}')
|
||||
new_reqs = [req for req in req_list if req and req not in _installed_requirements]
|
||||
|
||||
if not new_reqs:
|
||||
return
|
||||
|
||||
log.info(f'Installing requirements: {" ".join(new_reqs)}')
|
||||
subprocess.check_call(
|
||||
[sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + req_list + PIP_PACKAGE_INDEX_OPTIONS
|
||||
[sys.executable, '-m', 'pip', 'install'] + PIP_OPTIONS + new_reqs + PIP_PACKAGE_INDEX_OPTIONS
|
||||
)
|
||||
_installed_requirements.update(new_reqs)
|
||||
except Exception as e:
|
||||
log.error(f'Error installing packages: {" ".join(req_list)}')
|
||||
log.error(f'Error installing packages: {" ".join(new_reqs)}')
|
||||
raise e
|
||||
|
||||
else:
|
||||
|
||||
@@ -35,7 +35,7 @@ def prompt_variables_template(template: str, variables: dict[str, str]) -> str:
|
||||
return template
|
||||
|
||||
|
||||
def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
||||
async def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
||||
USER_VARIABLES = {}
|
||||
|
||||
if user:
|
||||
@@ -58,6 +58,19 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# Resolve user groups from DB only when the template uses {{USER_GROUPS}}
|
||||
groups = ''
|
||||
if '{{USER_GROUPS}}' in template:
|
||||
user_id = user.get('id')
|
||||
if user_id:
|
||||
try:
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id)
|
||||
groups = ', '.join(g.name for g in user_groups)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
USER_VARIABLES = {
|
||||
'name': str(user.get('name')),
|
||||
'email': str(user.get('email')),
|
||||
@@ -66,6 +79,7 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
||||
'gender': str(user.get('gender')),
|
||||
'birth_date': str(birth_date),
|
||||
'age': str(age),
|
||||
'groups': groups,
|
||||
}
|
||||
|
||||
# Get the current date
|
||||
@@ -88,6 +102,7 @@ def prompt_template(template: str, user: Optional[Any] = None) -> str:
|
||||
template = template.replace('{{USER_BIRTH_DATE}}', USER_VARIABLES.get('birth_date', 'Unknown'))
|
||||
template = template.replace('{{USER_AGE}}', str(USER_VARIABLES.get('age', 'Unknown')))
|
||||
template = template.replace('{{USER_LOCATION}}', USER_VARIABLES.get('location', 'Unknown'))
|
||||
template = template.replace('{{USER_GROUPS}}', USER_VARIABLES.get('groups', ''))
|
||||
|
||||
return template
|
||||
|
||||
@@ -243,11 +258,11 @@ def replace_messages_variable(template: str, messages: Optional[list[dict]] = No
|
||||
|
||||
# Let the context given here not distort the question,
|
||||
# but illuminate it, so that the answer serves the one who asked.
|
||||
def rag_template(template: str, context: str, query: str):
|
||||
async def rag_template(template: str, context: str, query: str):
|
||||
if template.strip() == '':
|
||||
template = DEFAULT_RAG_TEMPLATE
|
||||
|
||||
template = prompt_template(template)
|
||||
template = await prompt_template(template)
|
||||
|
||||
if '[context]' not in template and '{{CONTEXT}}' not in template:
|
||||
log.debug("WARNING: The RAG template does not contain the '[context]' or '{{CONTEXT}}' placeholder.")
|
||||
@@ -282,51 +297,51 @@ def rag_template(template: str, context: str, query: str):
|
||||
return template
|
||||
|
||||
|
||||
def title_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
async def title_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(template, user)
|
||||
template = await prompt_template(template, user)
|
||||
|
||||
return template
|
||||
|
||||
|
||||
def follow_up_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
async def follow_up_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(template, user)
|
||||
template = await prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
def tags_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
async def tags_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(template, user)
|
||||
template = await prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
def image_prompt_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
async def image_prompt_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(template, user)
|
||||
template = await prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
def emoji_generation_template(template: str, prompt: str, user: Optional[Any] = None) -> str:
|
||||
async def emoji_generation_template(template: str, prompt: str, user: Optional[Any] = None) -> str:
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = prompt_template(template, user)
|
||||
template = await prompt_template(template, user)
|
||||
|
||||
return template
|
||||
|
||||
|
||||
def autocomplete_generation_template(
|
||||
async def autocomplete_generation_template(
|
||||
template: str,
|
||||
prompt: str,
|
||||
messages: Optional[list[dict]] = None,
|
||||
@@ -337,16 +352,16 @@ def autocomplete_generation_template(
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(template, user)
|
||||
template = await prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
def query_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
async def query_generation_template(template: str, messages: list[dict], user: Optional[Any] = None) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(template, user)
|
||||
template = await prompt_template(template, user)
|
||||
return template
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import copy
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
@@ -7,6 +8,7 @@ import aiohttp
|
||||
import asyncio
|
||||
import yaml
|
||||
import json
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import FieldInfo
|
||||
@@ -45,6 +47,7 @@ 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_ALLOW_REDIRECTS,
|
||||
AIOHTTP_CLIENT_TIMEOUT,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
|
||||
@@ -54,7 +57,7 @@ from open_webui.env import (
|
||||
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
|
||||
REDIS_KEY_PREFIX,
|
||||
)
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.headers import include_user_info_headers, get_custom_headers
|
||||
from open_webui.tools.builtin import (
|
||||
search_web,
|
||||
fetch_url,
|
||||
@@ -100,7 +103,6 @@ from open_webui.tools.builtin import (
|
||||
delete_calendar_event,
|
||||
)
|
||||
|
||||
import copy
|
||||
from open_webui.utils.access_control import has_permission
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -189,10 +191,11 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
|
||||
log.warning(f'Access denied to tool {tool_id} for user {user.id}')
|
||||
continue
|
||||
|
||||
module = request.app.state.TOOLS.get(tool_id, None)
|
||||
if module is None:
|
||||
module, _ = await load_tool_module_by_id(tool_id)
|
||||
module = request.app.state.TOOLS.get(tool_id)
|
||||
if module is None or request.app.state.TOOL_CONTENTS.get(tool_id) != tool.content:
|
||||
module, _ = await load_tool_module_by_id(tool_id, content=tool.content)
|
||||
request.app.state.TOOLS[tool_id] = module
|
||||
request.app.state.TOOL_CONTENTS[tool_id] = tool.content
|
||||
|
||||
__user__ = {
|
||||
**extra_params['__user__'],
|
||||
@@ -336,8 +339,9 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
|
||||
|
||||
connection_headers = tool_server_connection.get('headers', None)
|
||||
if connection_headers and isinstance(connection_headers, dict):
|
||||
for key, value in connection_headers.items():
|
||||
headers[key] = value
|
||||
metadata = extra_params.get('__metadata__', {})
|
||||
custom_headers = get_custom_headers(connection_headers, user, metadata)
|
||||
headers.update(custom_headers)
|
||||
|
||||
# Add user info headers if enabled
|
||||
if ENABLE_FORWARD_USER_INFO_HEADERS and user:
|
||||
@@ -726,7 +730,6 @@ def clean_properties(schema: dict):
|
||||
|
||||
|
||||
def clean_openai_tool_schema(spec: dict) -> dict:
|
||||
import copy
|
||||
|
||||
cleaned_spec = copy.deepcopy(spec)
|
||||
|
||||
@@ -759,6 +762,11 @@ def get_tool_specs(tool_module: object) -> list[dict]:
|
||||
return specs
|
||||
|
||||
|
||||
# Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*)
|
||||
# and non-operation path-item fields (summary, description, servers, parameters).
|
||||
OPENAPI_HTTP_METHODS = {'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'}
|
||||
|
||||
|
||||
def resolve_schema(schema, components, resolved_schemas=None):
|
||||
"""
|
||||
Recursively resolves a JSON schema using OpenAPI components.
|
||||
@@ -795,6 +803,13 @@ def resolve_schema(schema, components, resolved_schemas=None):
|
||||
if 'items' in resolved_schema:
|
||||
resolved_schema['items'] = resolve_schema(resolved_schema['items'], components)
|
||||
|
||||
# Resolve composition keywords (oneOf, anyOf, allOf) which may contain $ref
|
||||
for keyword in ('oneOf', 'anyOf', 'allOf'):
|
||||
if keyword in resolved_schema and isinstance(resolved_schema[keyword], list):
|
||||
resolved_schema[keyword] = [
|
||||
resolve_schema(inner, components, resolved_schemas) for inner in resolved_schema[keyword]
|
||||
]
|
||||
|
||||
return resolved_schema
|
||||
|
||||
|
||||
@@ -811,7 +826,20 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
||||
tool_payload = []
|
||||
|
||||
for path, methods in openapi_spec.get('paths', {}).items():
|
||||
if not isinstance(methods, dict):
|
||||
continue
|
||||
|
||||
# Path-level parameters apply to all operations under this path
|
||||
# unless overridden at the operation level (matched by name + in).
|
||||
path_level_params = methods.get('parameters', [])
|
||||
if not isinstance(path_level_params, list):
|
||||
path_level_params = []
|
||||
|
||||
for method, operation in methods.items():
|
||||
if method not in OPENAPI_HTTP_METHODS:
|
||||
continue
|
||||
if not isinstance(operation, dict):
|
||||
continue
|
||||
if operation.get('operationId'):
|
||||
tool = {
|
||||
'name': operation.get('operationId'),
|
||||
@@ -822,7 +850,21 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
||||
'parameters': {'type': 'object', 'properties': {}, 'required': []},
|
||||
}
|
||||
|
||||
for param in operation.get('parameters', []):
|
||||
# Merge path-level and operation-level parameters.
|
||||
# Operation-level params override path-level params with the
|
||||
# same (name, in) pair per the OpenAPI spec.
|
||||
op_params = operation.get('parameters', [])
|
||||
if not isinstance(op_params, list):
|
||||
op_params = []
|
||||
merged_params = {}
|
||||
for param in path_level_params:
|
||||
if isinstance(param, dict) and param.get('name'):
|
||||
merged_params[(param['name'], param.get('in', ''))] = param
|
||||
for param in op_params:
|
||||
if isinstance(param, dict) and param.get('name'):
|
||||
merged_params[(param['name'], param.get('in', ''))] = param
|
||||
|
||||
for param in merged_params.values():
|
||||
param_name = param.get('name')
|
||||
if not param_name:
|
||||
continue
|
||||
@@ -871,29 +913,40 @@ def convert_openapi_to_tool_payload(openapi_spec):
|
||||
|
||||
|
||||
async def set_tool_servers(request: Request):
|
||||
request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS)
|
||||
try:
|
||||
request.app.state.TOOL_SERVERS = await get_tool_servers_data(request.app.state.config.TOOL_SERVER_CONNECTIONS)
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching tool server data: {e}')
|
||||
request.app.state.TOOL_SERVERS = getattr(request.app.state, 'TOOL_SERVERS', None) or []
|
||||
|
||||
if request.app.state.redis is not None:
|
||||
await request.app.state.redis.set(
|
||||
f'{REDIS_KEY_PREFIX}:tool_servers', json.dumps(request.app.state.TOOL_SERVERS)
|
||||
)
|
||||
try:
|
||||
if request.app.state.redis is not None:
|
||||
await request.app.state.redis.set(
|
||||
f'{REDIS_KEY_PREFIX}:tool_servers', json.dumps(request.app.state.TOOL_SERVERS)
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(f'Error caching tool_servers to Redis: {e}')
|
||||
|
||||
return request.app.state.TOOL_SERVERS
|
||||
|
||||
|
||||
async def get_tool_servers(request: Request):
|
||||
tool_servers = []
|
||||
if request.app.state.redis is not None:
|
||||
try:
|
||||
tool_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers'))
|
||||
request.app.state.TOOL_SERVERS = tool_servers
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching tool_servers from Redis: {e}')
|
||||
try:
|
||||
tool_servers = []
|
||||
if request.app.state.redis is not None:
|
||||
try:
|
||||
tool_servers = json.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers'))
|
||||
request.app.state.TOOL_SERVERS = tool_servers
|
||||
except Exception as e:
|
||||
log.error(f'Error fetching tool_servers from Redis: {e}')
|
||||
|
||||
if not tool_servers:
|
||||
tool_servers = await set_tool_servers(request)
|
||||
if not tool_servers:
|
||||
tool_servers = await set_tool_servers(request)
|
||||
|
||||
return tool_servers
|
||||
return tool_servers
|
||||
except Exception as e:
|
||||
log.error(f'Failed to load tool servers, skipping: {e}')
|
||||
return getattr(request.app.state, 'TOOL_SERVERS', None) or []
|
||||
|
||||
|
||||
async def get_terminal_cwd(
|
||||
@@ -1154,22 +1207,17 @@ async def get_tool_server_data(url: str, headers: Optional[dict]) -> Dict[str, A
|
||||
error_body = await response.json()
|
||||
raise Exception(error_body)
|
||||
|
||||
text_content = None
|
||||
text_content = await response.text()
|
||||
|
||||
# Check if URL ends with .yaml or .yml to determine format
|
||||
if url.lower().endswith(('.yaml', '.yml')):
|
||||
text_content = await response.text()
|
||||
res = yaml.safe_load(text_content)
|
||||
else:
|
||||
text_content = await response.text()
|
||||
|
||||
try:
|
||||
res = json.loads(text_content)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
res = json.loads(text_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fall back to YAML for non-.yml URLs that aren't valid JSON
|
||||
res = yaml.safe_load(text_content)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
except Exception as err:
|
||||
log.exception(f'Could not fetch tool server spec from {url}')
|
||||
@@ -1297,7 +1345,11 @@ async def execute_tool_server(
|
||||
|
||||
matching_route = None
|
||||
for route_path, methods in paths.items():
|
||||
if not isinstance(methods, dict):
|
||||
continue
|
||||
for http_method, operation in methods.items():
|
||||
if http_method not in OPENAPI_HTTP_METHODS:
|
||||
continue
|
||||
if isinstance(operation, dict) and operation.get('operationId') == name:
|
||||
matching_route = (route_path, methods)
|
||||
break
|
||||
@@ -1311,6 +1363,10 @@ async def execute_tool_server(
|
||||
|
||||
method_entry = None
|
||||
for http_method, operation in methods.items():
|
||||
if http_method not in OPENAPI_HTTP_METHODS:
|
||||
continue
|
||||
if not isinstance(operation, dict):
|
||||
continue
|
||||
if operation.get('operationId') == name:
|
||||
method_entry = (http_method.lower(), operation)
|
||||
break
|
||||
@@ -1324,7 +1380,22 @@ async def execute_tool_server(
|
||||
query_params = {}
|
||||
body_params = {}
|
||||
|
||||
for param in operation.get('parameters', []):
|
||||
# Merge path-level and operation-level parameters for execution.
|
||||
path_level_params = methods.get('parameters', [])
|
||||
if not isinstance(path_level_params, list):
|
||||
path_level_params = []
|
||||
op_params = operation.get('parameters', [])
|
||||
if not isinstance(op_params, list):
|
||||
op_params = []
|
||||
merged_params = {}
|
||||
for param in path_level_params:
|
||||
if isinstance(param, dict) and param.get('name'):
|
||||
merged_params[(param['name'], param.get('in', ''))] = param
|
||||
for param in op_params:
|
||||
if isinstance(param, dict) and param.get('name'):
|
||||
merged_params[(param['name'], param.get('in', ''))] = param
|
||||
|
||||
for param in merged_params.values():
|
||||
param_name = param.get('name')
|
||||
if not param_name:
|
||||
continue
|
||||
@@ -1342,11 +1413,10 @@ async def execute_tool_server(
|
||||
|
||||
final_url = f'{url.rstrip("/")}{route_path}'
|
||||
for key, value in path_params.items():
|
||||
final_url = final_url.replace(f'{{{key}}}', str(value))
|
||||
final_url = final_url.replace(f'{{{key}}}', quote(str(value), safe=''))
|
||||
|
||||
if query_params:
|
||||
query_string = '&'.join(f'{k}={v}' for k, v in query_params.items())
|
||||
final_url = f'{final_url}?{query_string}'
|
||||
final_url = f'{final_url}?{urlencode(query_params)}'
|
||||
|
||||
if operation.get('requestBody', {}).get('content'):
|
||||
if params:
|
||||
@@ -1364,7 +1434,7 @@ async def execute_tool_server(
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
allow_redirects=False,
|
||||
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
@@ -1389,7 +1459,7 @@ async def execute_tool_server(
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
|
||||
allow_redirects=False,
|
||||
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
text = await response.text()
|
||||
|
||||
@@ -3,17 +3,13 @@
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Matches the OWUI-generated profile image route. ``[^/?#]+`` accepts
|
||||
# any user-ID without allowing path-traversal or query/fragment injection,
|
||||
# and the ``$`` anchor rejects trailing path components.
|
||||
from open_webui.env import PROFILE_IMAGE_ALLOWED_MIME_TYPES
|
||||
|
||||
_USER_PROFILE_IMAGE_RE = re.compile(r'^/api/v1/users/[^/?#]+/profile/image$')
|
||||
|
||||
# Validates MIME type and structure of base64 data URIs. Only the prefix
|
||||
# is checked — validating the full base64 payload would mean running a
|
||||
# regex across megabytes of data on every Pydantic instantiation for zero
|
||||
# security benefit (corrupt base64 simply renders a broken image, same as
|
||||
# a 404 URL). SVG is intentionally excluded: it can carry embedded scripts.
|
||||
_SAFE_DATA_URI_RE = re.compile(r'^data:image/(png|jpeg|gif|webp);base64,', re.IGNORECASE)
|
||||
# Data-URI prefix validator derived from PROFILE_IMAGE_ALLOWED_MIME_TYPES.
|
||||
_mime_suffixes = '|'.join(re.escape(t.split('/')[-1]) for t in sorted(PROFILE_IMAGE_ALLOWED_MIME_TYPES))
|
||||
_SAFE_DATA_URI_RE = re.compile(rf'^data:image/({_mime_suffixes});base64,', re.IGNORECASE)
|
||||
|
||||
# Exact relative paths accepted as profile images. These are the only
|
||||
# static-asset paths OWUI itself assigns; no prefix/wildcard matching is
|
||||
|
||||
+39
-5
@@ -16,13 +16,26 @@ Based on a precedent of an unacceptable degree of spamming and unsolicited commu
|
||||
|
||||
Any reports or solicitations arriving from sources other than our designated GitHub repository will be dismissed without consideration. We’ve seen how external engagements can dilute and compromise the integrity of community-driven projects, and we’re not here to gamble with the security and privacy of our user community.
|
||||
|
||||
## Foreign CNAs and Vendor Disposition
|
||||
|
||||
When a report is filed via GitHub Security Advisories and the maintainers close it as out-of-scope per this policy, that closure is the **vendor's disposition** of the issue. A CVE Numbering Authority (CNA) that mints a CVE for such an issue without reflecting that vendor disposition in the resulting record is acting against vendor disposition.
|
||||
|
||||
We respond to such records by:
|
||||
|
||||
1. Filing a **REJECT** request with the CVE Program (with **DISPUTED** as fallback);
|
||||
2. Cataloging the record publicly, naming the issuing CNA;
|
||||
3. Refusing to provide vendor statements, version mappings, fix references, or any other coordination that would lend authority to the record;
|
||||
4. Escalating repeated patterns from a single CNA to the CVE Program Root.
|
||||
|
||||
**Channel compliance does not entitle a CNA to override vendor disposition.** Reporters who escalate a closed-as-out-of-scope GHSA report to a third-party CNA after vendor disposition has been issued are likewise considered to have acted against vendor disposition, and will be permanently barred from future GHSA submissions.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Reports not submitted through our designated GitHub repository will be disregarded, and we will categorically reject invitations to collaborate on external platforms. Our aggressive stance on this matter underscores our commitment to a secure, transparent, and open community where all operations are visible and contributors are accountable.
|
||||
|
||||
We appreciate the community's interest in identifying potential vulnerabilities. However, effective immediately, we will **not** accept low-effort vulnerability reports. Ensure that **submissions are constructive, actionable, reproducible, well documented and adhere to the following guidelines**:
|
||||
|
||||
1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**.
|
||||
1. **Report MUST be a vulnerability:** A security vulnerability is an exploitable weakness where the system behaves in an unintended way, allowing attackers to bypass security controls, gain unauthorized access, execute arbitrary code, or escalate privileges. Configuration options, missing features, and expected protocol behavior are **not vulnerabilities**. A vulnerability must cross at least one of the security boundaries (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation). **These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them.**
|
||||
|
||||
2. **No Vague Reports**: Submissions such as "I found a vulnerability" without any details will be treated as spam and will not be accepted.
|
||||
|
||||
@@ -33,7 +46,7 @@ We appreciate the community's interest in identifying potential vulnerabilities.
|
||||
> [!NOTE]
|
||||
> A PoC (Proof of Concept) is a **demonstration of exploitation of a vulnerability**. Your PoC must show:
|
||||
>
|
||||
> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation)
|
||||
> 1. Exactly what security boundary was crossed (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation - These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them)
|
||||
> 2. How this vulnerability is triggered/abused (inputs, endpoints, UI actions, etc.)
|
||||
> 3. What actions the attacker can now perform
|
||||
> 4. What data/action becomes possible that should not be possible
|
||||
@@ -105,7 +118,14 @@ Your remediation guidance can include, for example:
|
||||
> - wrote comments with conflicting information
|
||||
> - used illogical and conflicting arguments
|
||||
|
||||
**Non-compliant submissions will be closed, and repeat or extreme violators may be banned.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
|
||||
12. **Self-Affecting Issues Are Not Vulnerabilities:** A vulnerability requires crossing a security boundary that affects **a party other than the reporter**. Crossing one of the five recognized security boundaries (Confidentiality, Integrity, Availability, Authenticity, Non-repudiation - These boundaries are interpreted broadly; equivalent concepts in other security frameworks fall within them) only against the reporter's own data, account, session, or environment is **not a vulnerability** - it is a bug, and belongs in the [Issue Tracker](https://github.com/open-webui/open-webui/issues), not in a security report.
|
||||
|
||||
> [!NOTE]
|
||||
> This rule is about **who is harmed**, not about severity. A user modifying or deleting their own data, impairing their own session, observing their own configuration, or disabling security controls on their own account is out of scope under this rule, regardless of impact.
|
||||
>
|
||||
> If the same action also affects another user, the operator, the host system, or shared resources, identify that second party clearly in the PoC, and we want to hear about it.
|
||||
|
||||
**Non-compliant submissions will be closed, and repeat or extreme violators may be banned from submitting reports.** Our goal is to foster a constructive reporting environment where quality submissions promote better security for all users.
|
||||
|
||||
## Where to report the vulnerability
|
||||
|
||||
@@ -114,10 +134,24 @@ If you feel like you are not able to follow ALL outlined requirements for vulner
|
||||
|
||||
## Expected Response Timeframe
|
||||
|
||||
Due to the volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by a large number of invalid AI-generated reports (see [AI report transparency](#ai-report-transparency)) — our capacity to respond is limited. Open WebUI is a community-driven project maintained by a small team, and security reports are handled alongside all other project responsibilities.
|
||||
Due to the very high volume of incoming vulnerability reports, issues, discussions, pull requests, and general project maintenance — lately compounded by an unbelievably high number of AI-generated reports (see [AI report transparency](#ai-report-transparency)) — our capacity to respond is limited. Open WebUI is a community-driven project maintained by a small team, and security reports are handled alongside all other project responsibilities.
|
||||
|
||||
**Please expect several weeks** for your report to be triaged, investigated, fixed, and published. While we aim to respond to every report as quickly as possible, it is normal to experience periods of silence lasting up to several weeks. **This does not mean your report has been ignored** — it means we have not yet had the capacity to address it. The entire process can realistically take multiple weeks from initial submission to final publication. We appreciate your patience and understanding.
|
||||
|
||||
## Report Handling
|
||||
|
||||
If you report a valid vulnerability that somebody else reported before you, we will close your report as a duplicate. The earliest filing is the one we will handle going forward, and we will not publish multiple advisories for the same vulnerability.
|
||||
|
||||
When multiple independent reporters describe the same vulnerability class but each demonstrates a **distinct and separate exploitation vector** — for example, the same missing authorization check reached through different endpoints — we will consolidate them into the earliest filing and credit every reporter who demonstrated a distinct path. Only one CVE will be issued for the consolidated advisory.
|
||||
|
||||
### Why duplicate reports don't receive credit
|
||||
|
||||
We credit only the earliest filer of a given vulnerability:
|
||||
|
||||
1. **The first report did the work.** By the time a later report arrives, triage and fix are already in motion. Later reports don't change the outcome or timeline; crediting them would misrepresent what moved the fix.
|
||||
2. **Credit-for-duplicates incentivizes flooding.** If similar-but-later filings earn credit, the rational play is to skim open advisories and file variations. We already see this pressure — the first-filer rule is what limits it.
|
||||
3. **Co-discovery is different from duplication.** Multiple reporters **are credited** on one advisory **when each contributes a _distinct_ finding** — different vector, different affected component, different sub-path the earlier filing does not cover. That is the consolidation rule above. Filing a duplicate of an existing report is not co-discovery.
|
||||
|
||||
## Confidential Disclosure
|
||||
|
||||
Vulnerability reports submitted through GitHub Security Advisories are **private and confidential**. Public disclosure of **ANY** details related to a submitted vulnerability report is **STRICTLY PROHIBITED** until the advisory has been **fully published** — not merely when a CVE ID has been assigned, but when the advisory itself is publicly visible.
|
||||
@@ -157,4 +191,4 @@ For any other immediate concerns and questions, please create an issue in our [i
|
||||
|
||||
---
|
||||
|
||||
_Last updated on **2026-03-20**._
|
||||
_Last updated on **2026-05-04**._
|
||||
|
||||
Generated
+11
-11
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.5",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.5.0",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
@@ -3582,9 +3582,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.58.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz",
|
||||
"integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==",
|
||||
"version": "2.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz",
|
||||
"integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -10989,9 +10989,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
@@ -11847,9 +11847,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
|
||||
@@ -328,7 +328,7 @@ export const userSignOut = async () => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signout`, {
|
||||
method: 'GET',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
|
||||
+39
-20
@@ -301,34 +301,53 @@ export const getSharedChatList = async (token: string = '', page: number = 1, fi
|
||||
};
|
||||
|
||||
export const getAllChats = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/all`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/x-ndjson',
|
||||
...(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;
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return res;
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Response body is not readable');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const chats: object[] = [];
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
// Keep the last potentially incomplete line in the buffer
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
chats.push(JSON.parse(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data in the buffer
|
||||
const remaining = buffer.trim();
|
||||
if (remaining) {
|
||||
chats.push(JSON.parse(remaining));
|
||||
}
|
||||
|
||||
return chats;
|
||||
};
|
||||
|
||||
export const getChatListBySearchText = async (token: string, text: string, page: number = 1) => {
|
||||
|
||||
@@ -378,6 +378,7 @@ type RegisterOAuthClientForm = {
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
client_secret?: string;
|
||||
oauth_server_url?: string;
|
||||
};
|
||||
|
||||
export const registerOAuthClient = async (
|
||||
|
||||
+83
-15
@@ -4,6 +4,19 @@ import { getOpenAIModelsDirect } from './openai';
|
||||
|
||||
const TOOL_SERVER_FETCH_TIMEOUT = 10000;
|
||||
|
||||
// Valid HTTP methods per OpenAPI 3.x – used to skip extension keys (x-*)
|
||||
// and non-operation path-item fields (summary, description, servers, parameters).
|
||||
const OPENAPI_HTTP_METHODS = new Set([
|
||||
'get',
|
||||
'put',
|
||||
'post',
|
||||
'delete',
|
||||
'options',
|
||||
'head',
|
||||
'patch',
|
||||
'trace'
|
||||
]);
|
||||
|
||||
// Every request sent from here is a petition. May it reach
|
||||
// the one for whom it was intended, and return answered.
|
||||
export const getModels = async (
|
||||
@@ -159,6 +172,39 @@ export const getModels = async (
|
||||
return models;
|
||||
};
|
||||
|
||||
export const unloadModel = async (token: string, model: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_BASE_URL}/api/models/unload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({ model })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
if ('detail' in err) {
|
||||
error = err.detail;
|
||||
} else {
|
||||
error = err;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
type ChatCompletedForm = {
|
||||
model: string;
|
||||
messages: Record<string, unknown>[];
|
||||
@@ -495,9 +541,15 @@ export const executeToolServer = async (
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
// Find the matching operationId in the OpenAPI spec
|
||||
// Find the matching operationId in the OpenAPI spec (only valid HTTP methods)
|
||||
const matchingRoute = Object.entries(serverData.openapi.paths).find(([_, methods]) =>
|
||||
Object.entries(methods as any).some(([__, operation]: any) => operation.operationId === name)
|
||||
Object.entries(methods as any).some(
|
||||
([method, operation]: any) =>
|
||||
OPENAPI_HTTP_METHODS.has(method) &&
|
||||
operation &&
|
||||
typeof operation === 'object' &&
|
||||
operation.operationId === name
|
||||
)
|
||||
);
|
||||
|
||||
if (!matchingRoute) {
|
||||
@@ -507,7 +559,11 @@ export const executeToolServer = async (
|
||||
const [routePath, methods] = matchingRoute;
|
||||
|
||||
const methodEntry = Object.entries(methods as any).find(
|
||||
([_, operation]: any) => operation.operationId === name
|
||||
([method, operation]: any) =>
|
||||
OPENAPI_HTTP_METHODS.has(method) &&
|
||||
operation &&
|
||||
typeof operation === 'object' &&
|
||||
operation.operationId === name
|
||||
);
|
||||
|
||||
if (!methodEntry) {
|
||||
@@ -516,24 +572,36 @@ export const executeToolServer = async (
|
||||
|
||||
const [httpMethod, operation]: [string, any] = methodEntry;
|
||||
|
||||
// Merge path-level and operation-level parameters.
|
||||
// Operation-level params override path-level params with the same (name, in).
|
||||
const pathLevelParams: any[] = Array.isArray((methods as any).parameters)
|
||||
? (methods as any).parameters
|
||||
: [];
|
||||
const opParams: any[] = Array.isArray(operation.parameters) ? operation.parameters : [];
|
||||
const mergedParams = new Map();
|
||||
for (const param of pathLevelParams) {
|
||||
if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param);
|
||||
}
|
||||
for (const param of opParams) {
|
||||
if (param?.name) mergedParams.set(`${param.name}:${param.in ?? ''}`, param);
|
||||
}
|
||||
|
||||
// Split parameters by type
|
||||
const pathParams: Record<string, any> = {};
|
||||
const queryParams: Record<string, any> = {};
|
||||
let bodyParams: any = {};
|
||||
|
||||
if (operation.parameters) {
|
||||
operation.parameters.forEach((param: any) => {
|
||||
const paramName = param?.name;
|
||||
if (!paramName) return;
|
||||
const paramIn = param?.in;
|
||||
if (params.hasOwnProperty(paramName)) {
|
||||
if (paramIn === 'path') {
|
||||
pathParams[paramName] = params[paramName];
|
||||
} else if (paramIn === 'query') {
|
||||
queryParams[paramName] = params[paramName];
|
||||
}
|
||||
for (const param of mergedParams.values()) {
|
||||
const paramName = param?.name;
|
||||
if (!paramName) continue;
|
||||
const paramIn = param?.in;
|
||||
if (params.hasOwnProperty(paramName)) {
|
||||
if (paramIn === 'path') {
|
||||
pathParams[paramName] = params[paramName];
|
||||
} else if (paramIn === 'query') {
|
||||
queryParams[paramName] = params[paramName];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let finalUrl = `${url}${routePath}`;
|
||||
|
||||
@@ -36,9 +36,12 @@
|
||||
let auth_type = 'bearer';
|
||||
|
||||
let connectionType = 'external';
|
||||
let azure = false;
|
||||
let provider = '';
|
||||
$: azure =
|
||||
(url.includes('azure.') || url.includes('cognitive.microsoft.com')) && !direct ? true : false;
|
||||
provider === 'azure' ||
|
||||
((url.includes('azure.') || url.includes('cognitive.microsoft.com')) &&
|
||||
!direct &&
|
||||
provider === '');
|
||||
|
||||
let prefixId = '';
|
||||
let enable = true;
|
||||
@@ -98,7 +101,7 @@
|
||||
key,
|
||||
config: {
|
||||
auth_type,
|
||||
azure: azure,
|
||||
...(provider ? { provider } : azure ? { azure: true } : {}),
|
||||
api_version: apiVersion,
|
||||
...(_headers ? { headers: _headers } : {})
|
||||
}
|
||||
@@ -186,7 +189,8 @@
|
||||
connection_type: connectionType,
|
||||
auth_type,
|
||||
headers: headers ? JSON.parse(headers) : undefined,
|
||||
...(!ollama && azure ? { azure: true, api_version: apiVersion } : {}),
|
||||
...(provider ? { provider } : !ollama && azure ? { azure: true } : {}),
|
||||
...(azure ? { api_version: apiVersion } : {}),
|
||||
...(apiType ? { api_type: apiType } : {})
|
||||
}
|
||||
};
|
||||
@@ -223,7 +227,7 @@
|
||||
connectionType = connection.config?.connection_type ?? 'local';
|
||||
} else {
|
||||
connectionType = connection.config?.connection_type ?? 'external';
|
||||
azure = connection.config?.azure ?? false;
|
||||
provider = connection.config?.provider ?? (connection.config?.azure ? 'azure' : '');
|
||||
apiVersion = connection.config?.api_version ?? '';
|
||||
apiType = connection.config?.api_type ?? '';
|
||||
}
|
||||
@@ -491,22 +495,22 @@
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex flex-row justify-between items-center w-full mt-2">
|
||||
<label
|
||||
for="prefix-id-input"
|
||||
for="provider-select"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Provider Type')}</label
|
||||
>{$i18n.t('Provider')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
<button
|
||||
on:click={() => {
|
||||
azure = !azure;
|
||||
}}
|
||||
type="button"
|
||||
class=" text-xs text-gray-700 dark:text-gray-300"
|
||||
<select
|
||||
id="provider-select"
|
||||
bind:value={provider}
|
||||
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
|
||||
>
|
||||
{azure ? $i18n.t('Azure OpenAI') : $i18n.t('OpenAI')}
|
||||
</button>
|
||||
<option value="">{$i18n.t('Default')}</option>
|
||||
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
|
||||
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
|
||||
let oauthClientId = '';
|
||||
let oauthClientSecret = '';
|
||||
let oauthServerUrl = '';
|
||||
|
||||
let enable = true;
|
||||
let loading = false;
|
||||
@@ -86,10 +87,17 @@
|
||||
// client_id is the tool server ID (used as the internal lookup key for both flows).
|
||||
// For static, client_secret signals the backend to use the static credential path.
|
||||
// The actual OAuth client_id/secret come from the connection info at save time.
|
||||
const formData: { url: string; client_id: string; client_secret?: string } = {
|
||||
const formData: {
|
||||
url: string;
|
||||
client_id: string;
|
||||
client_secret?: string;
|
||||
oauth_server_url?: string;
|
||||
} = {
|
||||
url: url,
|
||||
client_id: id,
|
||||
...(auth_type === 'oauth_2.1_static' ? { client_secret: oauthClientSecret } : {})
|
||||
...(auth_type === 'oauth_2.1_static'
|
||||
? { client_secret: oauthClientSecret, oauth_server_url: oauthServerUrl }
|
||||
: {})
|
||||
};
|
||||
|
||||
const res = await registerOAuthClient(localStorage.token, formData, 'mcp').catch((err) => {
|
||||
@@ -336,7 +344,11 @@
|
||||
description: description,
|
||||
...(oauthClientInfo ? { oauth_client_info: oauthClientInfo } : {}),
|
||||
...(auth_type === 'oauth_2.1_static'
|
||||
? { oauth_client_id: oauthClientId, oauth_client_secret: oauthClientSecret }
|
||||
? {
|
||||
oauth_client_id: oauthClientId,
|
||||
oauth_client_secret: oauthClientSecret,
|
||||
oauth_server_url: oauthServerUrl
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
@@ -364,6 +376,7 @@
|
||||
oauthClientInfo = null;
|
||||
oauthClientId = '';
|
||||
oauthClientSecret = '';
|
||||
oauthServerUrl = '';
|
||||
|
||||
enable = true;
|
||||
functionNameFilterList = '';
|
||||
@@ -390,6 +403,7 @@
|
||||
oauthClientInfo = connection.info?.oauth_client_info ?? null;
|
||||
oauthClientId = connection.info?.oauth_client_id ?? '';
|
||||
oauthClientSecret = connection.info?.oauth_client_secret ?? '';
|
||||
oauthServerUrl = connection.info?.oauth_server_url ?? '';
|
||||
|
||||
enable = connection.config?.enable ?? true;
|
||||
functionNameFilterList = connection.config?.function_name_filter_list ?? '';
|
||||
@@ -730,6 +744,15 @@
|
||||
placeholder={$i18n.t('Client Secret')}
|
||||
required={false}
|
||||
/>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={oauthServerUrl}
|
||||
placeholder={$i18n.t('OAuth Server URL')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -194,10 +194,10 @@
|
||||
<div
|
||||
class="pt-0.5 pb-1 gap-1 flex flex-row justify-between items-center sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div class="text-lg font-medium px-0.5">
|
||||
<div class="text-lg font-medium px-0.5 shrink-0">
|
||||
{$i18n.t('Analytics')}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end min-w-0">
|
||||
{#if groups.length > 0}
|
||||
<select
|
||||
bind:value={selectedGroupId}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
||||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import Wrench from '$lib/components/icons/Wrench.svelte';
|
||||
@@ -22,7 +21,6 @@
|
||||
|
||||
let showManageModal = false;
|
||||
let showConfigModal = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
</script>
|
||||
|
||||
<AddConnectionModal
|
||||
@@ -35,7 +33,8 @@
|
||||
config: config
|
||||
}}
|
||||
onDelete={() => {
|
||||
showDeleteConfirmDialog = true;
|
||||
onDelete();
|
||||
showConfigModal = false;
|
||||
}}
|
||||
onSubmit={(connection) => {
|
||||
url = connection.url;
|
||||
@@ -44,14 +43,6 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
on:confirm={() => {
|
||||
onDelete();
|
||||
showConfigModal = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<ManageOllamaModal bind:show={showManageModal} urlIdx={idx} />
|
||||
|
||||
<div class="flex gap-1.5">
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import AddConnectionModal from '$lib/components/AddConnectionModal.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
||||
import { connect } from 'socket.io-client';
|
||||
|
||||
@@ -21,16 +20,8 @@
|
||||
export let config = {};
|
||||
|
||||
let showConfigModal = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
</script>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
on:confirm={() => {
|
||||
onDelete();
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddConnectionModal
|
||||
edit
|
||||
bind:show={showConfigModal}
|
||||
@@ -40,7 +31,8 @@
|
||||
config
|
||||
}}
|
||||
onDelete={() => {
|
||||
showDeleteConfirmDialog = true;
|
||||
onDelete();
|
||||
showConfigModal = false;
|
||||
}}
|
||||
onSubmit={(connection) => {
|
||||
url = connection.url;
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
|
||||
import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
|
||||
import AddTerminalServerModal from '$lib/components/AddTerminalServerModal.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
|
||||
import {
|
||||
getToolServerConnections,
|
||||
@@ -40,8 +39,6 @@
|
||||
let terminalConnections = [];
|
||||
let showAddTerminalModal = false;
|
||||
let editTerminalIdx: number | null = null;
|
||||
let showDeleteTerminalConfirm = false;
|
||||
let deleteTerminalIdx: number | null = null;
|
||||
|
||||
const addConnectionHandler = async (server) => {
|
||||
servers = [...servers, server];
|
||||
@@ -135,23 +132,12 @@
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (editTerminalIdx !== null) {
|
||||
deleteTerminalIdx = editTerminalIdx;
|
||||
showDeleteTerminalConfirm = true;
|
||||
removeTerminalConnection(editTerminalIdx);
|
||||
editTerminalIdx = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteTerminalConfirm}
|
||||
on:confirm={() => {
|
||||
if (deleteTerminalIdx !== null) {
|
||||
removeTerminalConnection(deleteTerminalIdx);
|
||||
deleteTerminalIdx = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<form
|
||||
class="flex flex-col h-full justify-between text-sm"
|
||||
on:submit|preventDefault={() => {
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
ENABLE_RETRIEVAL_QUERY_GENERATION: true,
|
||||
QUERY_GENERATION_PROMPT_TEMPLATE: '',
|
||||
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: '',
|
||||
ENABLE_VOICE_MODE_PROMPT: true,
|
||||
VOICE_MODE_PROMPT_TEMPLATE: ''
|
||||
};
|
||||
|
||||
@@ -236,24 +237,15 @@
|
||||
|
||||
<div class="mb-2.5 flex w-full items-center justify-between">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Voice Mode Custom Prompt')}
|
||||
{$i18n.t('Voice Mode Prompt')}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
state={taskConfig.VOICE_MODE_PROMPT_TEMPLATE != null}
|
||||
on:change={(e) => {
|
||||
if (e.detail) {
|
||||
taskConfig.VOICE_MODE_PROMPT_TEMPLATE = '';
|
||||
} else {
|
||||
taskConfig.VOICE_MODE_PROMPT_TEMPLATE = null;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Switch bind:state={taskConfig.ENABLE_VOICE_MODE_PROMPT} />
|
||||
</div>
|
||||
|
||||
{#if taskConfig.VOICE_MODE_PROMPT_TEMPLATE != null}
|
||||
{#if taskConfig.ENABLE_VOICE_MODE_PROMPT}
|
||||
<div class="mb-2.5">
|
||||
<div class=" mb-1 text-xs font-medium">{$i18n.t('Voice Mode Prompt')}</div>
|
||||
<div class=" mb-1 text-xs font-medium">{$i18n.t('Prompt Template')}</div>
|
||||
|
||||
<Tooltip
|
||||
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
'yacy',
|
||||
'google_pse',
|
||||
'brave',
|
||||
'brave_llm_context',
|
||||
'kagi',
|
||||
'mojeek',
|
||||
'bocha',
|
||||
@@ -357,6 +358,39 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'brave_llm_context'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Brave Search API Key')}
|
||||
</div>
|
||||
|
||||
<SensitiveInput
|
||||
placeholder={$i18n.t('Enter Brave Search API Key')}
|
||||
bind:value={webConfig.BRAVE_SEARCH_API_KEY}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<div class=" self-center text-xs font-medium mb-1">
|
||||
{$i18n.t('Context Tokens')}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="number"
|
||||
min="1024"
|
||||
max="32768"
|
||||
step="1024"
|
||||
placeholder={$i18n.t('Max tokens to retrieve (1024-32768, default 8192)')}
|
||||
bind:value={webConfig.BRAVE_SEARCH_CONTEXT_TOKENS}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if webConfig.WEB_SEARCH_ENGINE === 'kagi'}
|
||||
<div class="mb-2.5 flex w-full flex-col">
|
||||
<div>
|
||||
|
||||
@@ -392,6 +392,42 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if permissions.chat.share}
|
||||
<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('Chats Public Sharing')}
|
||||
</div>
|
||||
<Switch bind:state={permissions.sharing.public_chats} />
|
||||
</div>
|
||||
{#if defaultPermissions?.sharing?.public_chats && !permissions.sharing.public_chats}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('This is a default user permission and will remain enabled.')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if permissions.features.calendar}
|
||||
<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('Calendars Public Sharing')}
|
||||
</div>
|
||||
<Switch bind:state={permissions.sharing.public_calendars} />
|
||||
</div>
|
||||
{#if defaultPermissions?.sharing?.public_calendars && !permissions.sharing.public_calendars}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('This is a default user permission and will remain enabled.')}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
||||
|
||||
@@ -179,6 +179,21 @@
|
||||
<div class="text-[11px] text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
{$i18n.t('Calendars')}
|
||||
</div>
|
||||
<button
|
||||
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
|
||||
title={$i18n.t('New calendar')}
|
||||
on:click={onCreateCalendar}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="size-3 text-gray-400 dark:text-gray-500"
|
||||
><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#each calendars as cal (cal.id)}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, getContext } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
||||
import { createCalendar } from '$lib/apis/calendar';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let show = false;
|
||||
|
||||
let name = '';
|
||||
let color = '#3b82f6';
|
||||
let loading = false;
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3b82f6', // blue
|
||||
'#ef4444', // red
|
||||
'#22c55e', // green
|
||||
'#f59e0b', // amber
|
||||
'#8b5cf6', // violet
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#f97316' // orange
|
||||
];
|
||||
|
||||
function reset() {
|
||||
name = '';
|
||||
color = '#3b82f6';
|
||||
loading = false;
|
||||
}
|
||||
|
||||
$: if (show) reset();
|
||||
|
||||
const submitHandler = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error($i18n.t('Name is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const result = await createCalendar(localStorage.token, {
|
||||
name: name.trim(),
|
||||
color
|
||||
});
|
||||
if (result) {
|
||||
toast.success($i18n.t('Calendar created'));
|
||||
dispatch('created', result);
|
||||
show = false;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`${err}`);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<h3 class="text-base font-medium">{$i18n.t('New Calendar')}</h3>
|
||||
<button
|
||||
class="self-center shrink-0 ml-2"
|
||||
aria-label={$i18n.t('Close')}
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
<XMark className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="px-5 pb-2 flex flex-col gap-3">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
||||
<input
|
||||
class="w-full text-sm bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Calendar name')}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') submitHandler();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Color')}</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#each PRESET_COLORS as c}
|
||||
<button
|
||||
class="size-6 rounded-full transition-all border-2 {color === c
|
||||
? 'border-gray-800 dark:border-white scale-110'
|
||||
: 'border-transparent hover:scale-110'}"
|
||||
style="background-color: {c};"
|
||||
on:click={() => (color = c)}
|
||||
aria-label={c}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<label
|
||||
class="size-6 rounded-full overflow-hidden cursor-pointer border-2 transition-all {!PRESET_COLORS.includes(
|
||||
color
|
||||
)
|
||||
? 'border-gray-800 dark:border-white scale-110'
|
||||
: 'border-transparent hover:scale-110'}"
|
||||
style="background-color: {color};"
|
||||
title={$i18n.t('Custom color')}
|
||||
>
|
||||
<input type="color" bind:value={color} class="opacity-0 w-0 h-0 absolute" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom toolbar -->
|
||||
<div class="flex items-center justify-end px-4 pb-3.5 pt-2 gap-2">
|
||||
<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}
|
||||
>
|
||||
{$i18n.t('Create')}
|
||||
{#if loading}
|
||||
<span class="shrink-0"><Spinner /></span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -356,6 +356,7 @@
|
||||
<Markdown
|
||||
id={`${message.id}-reply-to`}
|
||||
content={message?.reply_to_message?.content}
|
||||
allowEmbeds={false}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
@@ -527,6 +528,7 @@
|
||||
id={message.id}
|
||||
content={message.content}
|
||||
paragraphTag="span"
|
||||
allowEmbeds={!!message?.meta?.model_id}
|
||||
/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
|
||||
class="text-gray-500 text-[10px] pl-1 self-center">({$i18n.t('edited')})</span
|
||||
>{/if}
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
import {
|
||||
artifactCode,
|
||||
chatId,
|
||||
config,
|
||||
settings,
|
||||
showArtifacts,
|
||||
showControls,
|
||||
artifactContents
|
||||
} from '$lib/stores';
|
||||
import { copyToClipboard, createMessagesList } from '$lib/utils';
|
||||
import { injectCsp } from '$lib/utils/csp';
|
||||
|
||||
import XMark from '../icons/XMark.svelte';
|
||||
import ArrowsPointingOut from '../icons/ArrowsPointingOut.svelte';
|
||||
@@ -242,7 +244,10 @@
|
||||
<iframe
|
||||
bind:this={iframeElement}
|
||||
title="Content"
|
||||
srcdoc={contents[selectedContentIdx].content}
|
||||
srcdoc={injectCsp(
|
||||
contents[selectedContentIdx].content,
|
||||
$config?.ui?.iframe_csp ?? ''
|
||||
)}
|
||||
class="w-full border-0 h-full rounded-none"
|
||||
sandbox="allow-scripts allow-downloads{($settings?.iframeSandboxAllowForms ?? false)
|
||||
? ' allow-forms'
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
import {
|
||||
archiveChatById,
|
||||
createNewChat,
|
||||
deleteChatById,
|
||||
getAllTags,
|
||||
getChatById,
|
||||
getChatList,
|
||||
@@ -101,6 +102,7 @@
|
||||
import Navbar from '$lib/components/chat/Navbar.svelte';
|
||||
import ChatControls from './ChatControls.svelte';
|
||||
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import DeleteConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import Placeholder from './Placeholder.svelte';
|
||||
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
|
||||
import NotificationToast from '../NotificationToast.svelte';
|
||||
@@ -119,8 +121,10 @@
|
||||
let controlPaneComponent: ChatControls | undefined;
|
||||
|
||||
let messageInput: MessageInput | undefined;
|
||||
let messagesRef: Messages | undefined;
|
||||
|
||||
let autoScroll = true;
|
||||
let isNearTop = true;
|
||||
let processing = '';
|
||||
let messagesContainerElement: HTMLDivElement;
|
||||
|
||||
@@ -180,6 +184,12 @@
|
||||
navigateHandler();
|
||||
}
|
||||
|
||||
let saveControlsTimer;
|
||||
$: if (!loading && !$temporaryChatEnabled && $chatId && params && chatFiles) {
|
||||
clearTimeout(saveControlsTimer);
|
||||
saveControlsTimer = setTimeout(saveControls, 400);
|
||||
}
|
||||
|
||||
const navigateHandler = async () => {
|
||||
// Mark the outgoing chat as read before loading the new one.
|
||||
// $chatId still holds the previous chat here — loadChat() updates it.
|
||||
@@ -187,6 +197,8 @@
|
||||
updateLastReadAt($chatId);
|
||||
}
|
||||
|
||||
clearTimeout(saveControlsTimer);
|
||||
await saveControls();
|
||||
loading = true;
|
||||
|
||||
prompt = '';
|
||||
@@ -287,7 +299,7 @@
|
||||
oldSelectedModelIds = structuredClone(selectedModelIds);
|
||||
};
|
||||
|
||||
const resetInput = () => {
|
||||
const resetInput = async () => {
|
||||
selectedToolIds = [];
|
||||
selectedFilterIds = [];
|
||||
pendingOAuthTools = [];
|
||||
@@ -296,10 +308,18 @@
|
||||
codeInterpreterEnabled = false;
|
||||
|
||||
if (selectedModelIds.filter((id) => id).length > 0) {
|
||||
setDefaults();
|
||||
await setDefaults();
|
||||
}
|
||||
};
|
||||
|
||||
/** Check whether a terminal ID references an available system or direct terminal. */
|
||||
const isTerminalAvailable = (tid: string): boolean => {
|
||||
return (
|
||||
($terminalServers ?? []).some((t) => t.id && t.id === tid) ||
|
||||
($settings?.terminalServers ?? []).some((s) => s.url === tid)
|
||||
);
|
||||
};
|
||||
|
||||
const setDefaults = async () => {
|
||||
if (!$tools) {
|
||||
tools.set(await getTools(localStorage.token));
|
||||
@@ -378,9 +398,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Set Default Terminal
|
||||
// Set Default Terminal — only if the referenced terminal actually exists
|
||||
if (model?.info?.meta?.terminalId) {
|
||||
selectedTerminalId.set(model.info.meta.terminalId);
|
||||
const tid = model.info.meta.terminalId;
|
||||
if (isTerminalAvailable(tid)) {
|
||||
selectedTerminalId.set(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -718,6 +741,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Clear stale selectedTerminalId if the referenced terminal no longer exists
|
||||
if ($selectedTerminalId && !isTerminalAvailable($selectedTerminalId)) {
|
||||
selectedTerminalId.set(null);
|
||||
}
|
||||
|
||||
const pageSubscribe = page.subscribe(async (p) => {
|
||||
if (p.url.pathname === '/') {
|
||||
await tick();
|
||||
@@ -807,6 +835,8 @@
|
||||
|
||||
return () => {
|
||||
try {
|
||||
clearTimeout(saveControlsTimer);
|
||||
saveControls();
|
||||
if (chatIdProp && !$temporaryChatEnabled) {
|
||||
updateLastReadAt(chatIdProp);
|
||||
}
|
||||
@@ -1190,7 +1220,7 @@
|
||||
|
||||
autoScroll = true;
|
||||
|
||||
resetInput();
|
||||
await resetInput();
|
||||
await chatId.set('');
|
||||
await chatTitle.set('');
|
||||
|
||||
@@ -1349,6 +1379,30 @@
|
||||
? chatContent.history
|
||||
: convertMessagesToHistory(chatContent.messages);
|
||||
|
||||
// Sanitize history: repair orphaned references from failed regenerations (#24424)
|
||||
for (const message of Object.values(history.messages)) {
|
||||
if (message.childrenIds) {
|
||||
message.childrenIds = message.childrenIds.filter(
|
||||
(childId) => history.messages[childId]
|
||||
);
|
||||
}
|
||||
}
|
||||
if (history.currentId && !history.messages[history.currentId]) {
|
||||
const messageIds = Object.keys(history.messages);
|
||||
let lastMessageId = null;
|
||||
for (const messageId of messageIds) {
|
||||
const message = history.messages[messageId];
|
||||
if (
|
||||
(message.childrenIds ?? []).length === 0 &&
|
||||
(!lastMessageId ||
|
||||
(message.timestamp ?? 0) > (history.messages[lastMessageId].timestamp ?? 0))
|
||||
) {
|
||||
lastMessageId = messageId;
|
||||
}
|
||||
}
|
||||
history.currentId = lastMessageId ?? messageIds[0] ?? null;
|
||||
}
|
||||
|
||||
chatTitle.set(chatContent.title);
|
||||
|
||||
params = chatContent?.params ?? {};
|
||||
@@ -1360,6 +1414,7 @@
|
||||
autoScroll = true;
|
||||
await tick();
|
||||
|
||||
// Mark all non-current assistant messages as done
|
||||
if (history.currentId) {
|
||||
for (const message of Object.values(history.messages)) {
|
||||
if (
|
||||
@@ -1373,23 +1428,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
const taskRes = await getTaskIdsByChatId(localStorage.token, $chatId).catch((error) => {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (taskRes) {
|
||||
taskIds = taskRes.task_ids;
|
||||
}
|
||||
|
||||
// If no active tasks and current message is incomplete, generation was interrupted
|
||||
// Reconcile active tasks with message state:
|
||||
// If the response is already done, remaining tasks are just background
|
||||
// work (follow-ups, title gen) that shouldn't block the input.
|
||||
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId)
|
||||
.then((res) => res?.task_ids ?? [])
|
||||
.catch(() => []);
|
||||
const currentMessage = history.currentId ? history.messages[history.currentId] : null;
|
||||
if (
|
||||
currentMessage &&
|
||||
currentMessage.role === 'assistant' &&
|
||||
!currentMessage.done &&
|
||||
(!taskIds || taskIds.length === 0)
|
||||
) {
|
||||
currentMessage.done = true;
|
||||
const responseComplete = currentMessage?.role === 'assistant' && currentMessage?.done;
|
||||
|
||||
if (pendingTaskIds.length > 0 && !responseComplete) {
|
||||
taskIds = pendingTaskIds;
|
||||
} else {
|
||||
taskIds = null;
|
||||
// No active tasks and message incomplete → generation was interrupted
|
||||
if (currentMessage?.role === 'assistant' && !currentMessage.done) {
|
||||
currentMessage.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
await tick();
|
||||
@@ -1408,9 +1463,36 @@
|
||||
top: messagesContainerElement.scrollHeight,
|
||||
behavior
|
||||
});
|
||||
|
||||
// content-visibility: auto causes the initial scrollHeight to be based on
|
||||
// estimated sizes (contain-intrinsic-size). After we scroll, previously
|
||||
// off-screen messages become visible and the browser resolves their actual
|
||||
// heights, which shifts scrollHeight. Re-layouts can cascade across frames
|
||||
// (new sizes reveal more content, triggering further size resolution), so
|
||||
// we re-scroll across two animation frames to land at the true bottom.
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTo({
|
||||
top: messagesContainerElement.scrollHeight,
|
||||
behavior
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
if (messagesContainerElement) {
|
||||
messagesContainerElement.scrollTo({
|
||||
top: messagesContainerElement.scrollHeight,
|
||||
behavior
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToTop = async () => {
|
||||
await messagesRef?.scrollToTop();
|
||||
};
|
||||
|
||||
let scrollRAF = null;
|
||||
let contentsRAF = null;
|
||||
const scheduleScrollToBottom = () => {
|
||||
@@ -1967,11 +2049,13 @@
|
||||
{
|
||||
messages = null,
|
||||
modelId = null,
|
||||
modelIdx = null
|
||||
modelIdx = null,
|
||||
regenerationPrompt = null
|
||||
}: {
|
||||
messages?: any[] | null;
|
||||
modelId?: string | null;
|
||||
modelIdx?: number | null;
|
||||
regenerationPrompt?: string | null;
|
||||
} = {}
|
||||
) => {
|
||||
if (autoScroll) {
|
||||
@@ -2083,7 +2167,10 @@
|
||||
_history,
|
||||
primaryResponseMessageId,
|
||||
_chatId,
|
||||
selectedModelIds.length > 1 ? messageIdsMap : undefined
|
||||
{
|
||||
messageIdsMap: selectedModelIds.length > 1 ? messageIdsMap : undefined,
|
||||
regenerationPrompt
|
||||
}
|
||||
);
|
||||
|
||||
if (chatEventEmitter) clearInterval(chatEventEmitter);
|
||||
@@ -2148,7 +2235,15 @@
|
||||
_history,
|
||||
responseMessageId,
|
||||
_chatId,
|
||||
messageIdsMap?: Record<string, string>
|
||||
{
|
||||
messageIdsMap,
|
||||
regenerationPrompt,
|
||||
continueResponse = false
|
||||
}: {
|
||||
messageIdsMap?: Record<string, string>;
|
||||
regenerationPrompt?: string | null;
|
||||
continueResponse?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const responseMessage = _history.messages[responseMessageId];
|
||||
const userMessage = _history.messages[responseMessage.parentId];
|
||||
@@ -2167,7 +2262,7 @@
|
||||
files.push(
|
||||
...(userMessage?.files ?? []).filter(
|
||||
(item) =>
|
||||
['doc', 'text', 'note', 'chat', 'collection'].includes(item.type) ||
|
||||
['doc', 'text', 'note', 'chat', 'collection', 'folder'].includes(item.type) ||
|
||||
(item.type === 'file' && !(item?.content_type ?? '').startsWith('image/'))
|
||||
)
|
||||
);
|
||||
@@ -2204,6 +2299,7 @@
|
||||
? { role: 'system', content: `${params?.system ?? $settings?.system ?? ''}` }
|
||||
: undefined
|
||||
].filter(Boolean);
|
||||
|
||||
if ($temporaryChatEnabled) {
|
||||
messages = [
|
||||
...messages,
|
||||
@@ -2347,6 +2443,8 @@
|
||||
...(messageIdsMap ? { message_ids: messageIdsMap } : {}),
|
||||
parent_id: userMessage?.parentId ?? null,
|
||||
user_message: userMessage,
|
||||
...(regenerationPrompt ? { regeneration_prompt: regenerationPrompt } : {}),
|
||||
...(continueResponse ? { assistant_message_id: responseMessageId } : {}),
|
||||
|
||||
background_tasks: {
|
||||
...(!$temporaryChatEnabled && !_chatId && (userMessage?.parentId ?? null) === null
|
||||
@@ -2416,6 +2514,16 @@
|
||||
window.history.replaceState(history.state, '', `/c/${res.chat_id}`);
|
||||
currentChatPage.set(1);
|
||||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
|
||||
// Persist chat-level params (system prompt, advanced
|
||||
// params) that the backend doesn't receive in the
|
||||
// chat completion request. Files are now persisted
|
||||
// by the backend at chat creation time.
|
||||
if (Object.keys(params).length > 0) {
|
||||
await updateChatById(localStorage.token, res.chat_id, {
|
||||
params: params
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2562,13 +2670,8 @@
|
||||
await sendMessage(history, userMessage.id, {
|
||||
...(suggestionPrompt
|
||||
? {
|
||||
messages: [
|
||||
...createMessagesList(history, message.id),
|
||||
{
|
||||
role: 'user',
|
||||
content: suggestionPrompt
|
||||
}
|
||||
]
|
||||
messages: createMessagesList(history, message.id),
|
||||
regenerationPrompt: suggestionPrompt
|
||||
}
|
||||
: {}),
|
||||
...((userMessage?.models ?? [...selectedModels]).length > 1
|
||||
@@ -2601,7 +2704,8 @@
|
||||
createMessagesList(history, responseMessage.id),
|
||||
history,
|
||||
responseMessage.id,
|
||||
_chatId
|
||||
_chatId,
|
||||
{ continueResponse: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2715,6 +2819,13 @@
|
||||
}
|
||||
};
|
||||
|
||||
const saveControls = async () => {
|
||||
if (!$chatId || $temporaryChatEnabled) return;
|
||||
await updateChatById(localStorage.token, $chatId, { params, files: chatFiles }).catch((err) =>
|
||||
console.error('[controls autosave]', err)
|
||||
);
|
||||
};
|
||||
|
||||
const MAX_DRAFT_LENGTH = 5000;
|
||||
let saveDraftTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -2777,6 +2888,33 @@
|
||||
toast.error($i18n.t('Failed to archive chat.'));
|
||||
}
|
||||
};
|
||||
|
||||
let showDeleteConfirm = false;
|
||||
|
||||
const deleteChatHandler = async (id: string) => {
|
||||
showDeleteConfirm = true;
|
||||
};
|
||||
|
||||
const confirmDeleteChat = async () => {
|
||||
const id = $chatId;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const res = await deleteChatById(localStorage.token, id);
|
||||
if (res) {
|
||||
currentChatPage.set(1);
|
||||
initNewChat();
|
||||
await goto('/');
|
||||
chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
allTags.set(await getAllTags(localStorage.token));
|
||||
toast.success($i18n.t('Chat deleted.'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting chat:', error);
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -2787,7 +2925,19 @@
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<audio id="audioElement" src="" style="display: none;"></audio>
|
||||
<audio id="audioElement" style="display: none;"></audio>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
bind:show={showDeleteConfirm}
|
||||
title={$i18n.t('Delete chat?')}
|
||||
on:confirm={() => {
|
||||
confirmDeleteChat();
|
||||
}}
|
||||
>
|
||||
<div class=" text-sm text-gray-500 flex-1 line-clamp-3">
|
||||
{$i18n.t('This will delete')} <span class=" font-semibold">{$chatTitle}</span>.
|
||||
</div>
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<EventConfirmDialog
|
||||
bind:show={showEventConfirmation}
|
||||
@@ -2859,7 +3009,9 @@
|
||||
bind:selectedModels
|
||||
shareEnabled={!!history.currentId}
|
||||
{initNewChat}
|
||||
scrollToTop={!isNearTop ? scrollToTop : null}
|
||||
{archiveChatHandler}
|
||||
{deleteChatHandler}
|
||||
{moveChatHandler}
|
||||
onSaveTempChat={async () => {
|
||||
try {
|
||||
@@ -2910,10 +3062,12 @@
|
||||
autoScroll =
|
||||
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
|
||||
messagesContainerElement.clientHeight + 5;
|
||||
isNearTop = messagesContainerElement.scrollTop <= 100;
|
||||
}}
|
||||
>
|
||||
<div class=" h-full w-full flex flex-col">
|
||||
<Messages
|
||||
bind:this={messagesRef}
|
||||
chatId={$chatId}
|
||||
bind:history
|
||||
bind:autoScroll
|
||||
|
||||
@@ -92,6 +92,35 @@
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
|
||||
// ── Sort state ──────────────────────────────────────────────────────
|
||||
type SortMode = 'name' | 'date';
|
||||
let sortBy: SortMode = 'name';
|
||||
let sortAsc = true;
|
||||
|
||||
const sortEntries = (items: FileEntry[]): FileEntry[] => {
|
||||
return [...items].sort((a, b) => {
|
||||
// Directories always first
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
if (sortBy === 'date') {
|
||||
const aTime = a.modified ?? 0;
|
||||
const bTime = b.modified ?? 0;
|
||||
return sortAsc ? aTime - bTime : bTime - aTime;
|
||||
}
|
||||
const cmp = a.name.localeCompare(b.name);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSort = (mode: SortMode) => {
|
||||
if (sortBy === mode) {
|
||||
sortAsc = !sortAsc;
|
||||
} else {
|
||||
sortBy = mode;
|
||||
sortAsc = mode === 'name'; // name defaults asc, date defaults asc (oldest first)
|
||||
}
|
||||
entries = sortEntries(entries);
|
||||
};
|
||||
|
||||
// ── Navigation history ──────────────────────────────────────────────
|
||||
type NavEntry = { path: string; file: string | null };
|
||||
let navHistory: NavEntry[] = [];
|
||||
@@ -341,10 +370,7 @@
|
||||
'Failed to load directory. Check your Terminal connection in Settings → Integrations.';
|
||||
entries = [];
|
||||
} else {
|
||||
entries = result.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
entries = sortEntries(result);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -429,7 +455,8 @@
|
||||
selectedExcelSheet = excelSheetNames[0];
|
||||
const { excelToTable } = await import('$lib/utils/excelToTable');
|
||||
const result = await excelToTable(wb.Sheets[selectedExcelSheet]);
|
||||
fileOfficeHtml = result.html;
|
||||
const DOMPurify = (await import('dompurify')).default;
|
||||
fileOfficeHtml = DOMPurify.sanitize(result.html);
|
||||
}
|
||||
} else if (ext === 'pptx') {
|
||||
const { pptxToImages } = await import('$lib/utils/pptxToHtml');
|
||||
@@ -918,6 +945,8 @@
|
||||
{loading}
|
||||
{canGoBack}
|
||||
{canGoForward}
|
||||
{sortBy}
|
||||
{sortAsc}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
onNavigate={loadDir}
|
||||
@@ -934,6 +963,7 @@
|
||||
onUploadFiles={handleUploadFiles}
|
||||
onDownloadDir={() => downloadFile(currentPath)}
|
||||
onMove={handleMove}
|
||||
onSort={toggleSort}
|
||||
>
|
||||
{#if fileImageUrl !== null || (fileOfficeSlides !== null && fileOfficeSlides.length > 0)}
|
||||
<Tooltip content={$i18n.t('Reset view')}>
|
||||
@@ -1253,7 +1283,8 @@
|
||||
selectedExcelSheet = sheet;
|
||||
const { excelToTable } = await import('$lib/utils/excelToTable');
|
||||
const result = await excelToTable(excelWorkbook.Sheets[sheet]);
|
||||
fileOfficeHtml = result.html;
|
||||
const DOMPurify = (await import('dompurify')).default;
|
||||
fileOfficeHtml = DOMPurify.sanitize(result.html);
|
||||
}}
|
||||
baseUrl={selectedTerminal?.url ?? ''}
|
||||
apiKey={selectedTerminal?.key ?? ''}
|
||||
@@ -1355,6 +1386,7 @@
|
||||
onRename={handleRename}
|
||||
onSelect={handleSelect}
|
||||
onLongPress={enterSelectionMode}
|
||||
showDate={sortBy === 'date'}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -30,6 +30,17 @@
|
||||
export let selectedPaths: Set<string> = new Set();
|
||||
export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {};
|
||||
export let onLongPress: () => void = () => {};
|
||||
export let showDate: boolean = false;
|
||||
|
||||
const formatRelativeTime = (epoch: number): string => {
|
||||
const diff = Math.floor(Date.now() / 1000) - epoch;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
|
||||
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo ago`;
|
||||
return `${Math.floor(diff / 31536000)}y ago`;
|
||||
};
|
||||
|
||||
let dragOverFolder = false;
|
||||
|
||||
@@ -271,7 +282,14 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if entry.type === 'file' && entry.size !== undefined && !renaming}
|
||||
{#if showDate && entry.modified}
|
||||
<span class="text-[10px] text-gray-400 shrink-0"
|
||||
>{formatRelativeTime(entry.modified)}</span
|
||||
>
|
||||
{/if}
|
||||
<span class="text-xs text-gray-400 shrink-0">{formatFileSize(entry.size)}</span>
|
||||
{:else if entry.type === 'directory' && showDate && entry.modified && !renaming}
|
||||
<span class="text-[10px] text-gray-400 shrink-0">{formatRelativeTime(entry.modified)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import FilePlusAlt from '../../icons/FilePlusAlt.svelte';
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
import Tooltip from '../../common/Tooltip.svelte';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
@@ -21,6 +22,11 @@
|
||||
export let onDownloadDir: () => void = () => {};
|
||||
export let onMove: (source: string, destFolder: string) => void = () => {};
|
||||
|
||||
// Sort controls
|
||||
export let sortBy: 'name' | 'date' = 'name';
|
||||
export let sortAsc: boolean = true;
|
||||
export let onSort: (mode: 'name' | 'date') => void = () => {};
|
||||
|
||||
// Back / forward navigation
|
||||
export let canGoBack = false;
|
||||
export let canGoForward = false;
|
||||
@@ -161,6 +167,78 @@
|
||||
</Tooltip>
|
||||
|
||||
{#if !selectedFile}
|
||||
<Dropdown align="end" sideOffset={4}>
|
||||
<Tooltip content={$i18n.t('Sort')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
aria-label={$i18n.t('Sort')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
d="M2 3.75A.75.75 0 0 1 2.75 3h11.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 3.75ZM2 7.5a.75.75 0 0 1 .75-.75h7.508a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 7.5ZM14 7a.75.75 0 0 1 .75.75v6.69l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V7.75A.75.75 0 0 1 14 7ZM2 11.25a.75.75 0 0 1 .75-.75h4.562a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<div
|
||||
class="min-w-[150px] rounded-2xl p-1 z-[9999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<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={() => onSort('name')}
|
||||
>
|
||||
<span class="flex-1 text-left">{$i18n.t('Name')}</span>
|
||||
{#if sortBy === 'name'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-3 text-gray-500 dark:text-gray-400 transition-transform {sortAsc
|
||||
? ''
|
||||
: 'rotate-180'}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.78 9.78a.75.75 0 0 1-1.06 0L8 7.06 5.28 9.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</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={() => onSort('date')}
|
||||
>
|
||||
<span class="flex-1 text-left">{$i18n.t('Date Modified')}</span>
|
||||
{#if sortBy === 'date'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-3 text-gray-500 dark:text-gray-400 transition-transform {sortAsc
|
||||
? ''
|
||||
: 'rotate-180'}"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.78 9.78a.75.75 0 0 1-1.06 0L8 7.06 5.28 9.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Tooltip content={$i18n.t('New Folder')}>
|
||||
<button
|
||||
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { getContext, tick } from 'svelte';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { settings } from '$lib/stores';
|
||||
import { settings, config } from '$lib/stores';
|
||||
import { injectCsp } from '$lib/utils/csp';
|
||||
import { isCodeFile } from '$lib/utils/codeHighlight';
|
||||
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
|
||||
import Spinner from '../../common/Spinner.svelte';
|
||||
@@ -411,7 +412,7 @@
|
||||
<div class="absolute top-0 left-0 right-0 bottom-0 z-10"></div>
|
||||
{/if}
|
||||
<iframe
|
||||
srcdoc={fileContent}
|
||||
srcdoc={injectCsp(fileContent, $config?.ui?.iframe_csp ?? '')}
|
||||
sandbox="allow-scripts allow-downloads{($settings?.iframeSandboxAllowForms ?? false)
|
||||
? ' allow-forms'
|
||||
: ''}{($settings?.iframeSandboxAllowSameOrigin ?? false) ? ' allow-same-origin' : ''}"
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
let confirmed = false;
|
||||
let interrupted = false;
|
||||
let assistantSpeaking = false;
|
||||
let muted = false;
|
||||
|
||||
let emoji = null;
|
||||
let camera = false;
|
||||
@@ -63,7 +64,12 @@
|
||||
|
||||
console.log(videoInputDevices);
|
||||
if (selectedVideoInputDeviceId === null && videoInputDevices.length > 0) {
|
||||
selectedVideoInputDeviceId = videoInputDevices[0].deviceId;
|
||||
const savedDeviceId = localStorage.getItem('selectedVideoInputDeviceId');
|
||||
if (savedDeviceId && videoInputDevices.some((d) => d.deviceId === savedDeviceId)) {
|
||||
selectedVideoInputDeviceId = savedDeviceId;
|
||||
} else {
|
||||
selectedVideoInputDeviceId = videoInputDevices[0].deviceId;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -150,6 +156,10 @@
|
||||
|
||||
const transcribeHandler = async (audioBlob) => {
|
||||
// Create a blob from the audio chunks
|
||||
if (!audioBlob || audioBlob.size < 100) {
|
||||
console.log('Audio blob too small or empty, skipping transcription');
|
||||
return;
|
||||
}
|
||||
|
||||
await tick();
|
||||
const file = blobToFile(audioBlob, 'recording.wav');
|
||||
@@ -231,6 +241,11 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (audioStream) {
|
||||
// hardware track muting disabled to prevent backend translation errors with malformed WebM files
|
||||
}
|
||||
|
||||
mediaRecorder = new MediaRecorder(audioStream);
|
||||
|
||||
mediaRecorder.onstart = () => {
|
||||
@@ -305,8 +320,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (assistantSpeaking && !($settings?.voiceInterruption ?? false)) {
|
||||
// Mute the audio if the assistant is speaking
|
||||
if (muted || (assistantSpeaking && !($settings?.voiceInterruption ?? false))) {
|
||||
// Suppress mic input when muted or when assistant is speaking without interruption enabled
|
||||
analyser.maxDecibels = 0;
|
||||
analyser.minDecibels = -1;
|
||||
} else {
|
||||
@@ -320,6 +335,10 @@
|
||||
// Calculate RMS level from time domain data
|
||||
rmsLevel = calculateRMS(timeDomainData);
|
||||
|
||||
if (muted || (assistantSpeaking && !($settings?.voiceInterruption ?? false))) {
|
||||
rmsLevel = 0;
|
||||
}
|
||||
|
||||
// Check if initial speech/noise has started
|
||||
const hasSound = domainData.some((value) => value > 0);
|
||||
if (hasSound) {
|
||||
@@ -622,6 +641,47 @@
|
||||
chatStreaming = false;
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
muted = !muted;
|
||||
if (muted && hasStartedSpeaking) {
|
||||
// Abort the ongoing recording so it doesn't accidentally send a partial sentence
|
||||
hasStartedSpeaking = false;
|
||||
confirmed = false;
|
||||
audioChunks = [];
|
||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let wasAssistantSpeaking = false;
|
||||
$: {
|
||||
if (assistantSpeaking && !wasAssistantSpeaking) {
|
||||
wasAssistantSpeaking = true;
|
||||
} else if (!assistantSpeaking && wasAssistantSpeaking) {
|
||||
wasAssistantSpeaking = false;
|
||||
// Auto unmute when AI finishes speaking
|
||||
if (muted) {
|
||||
muted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
// Only handle M key when not typing in an input/textarea
|
||||
if (e.key === 'm' || e.key === 'M') {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName !== 'INPUT' &&
|
||||
target.tagName !== 'TEXTAREA' &&
|
||||
!target.isContentEditable
|
||||
) {
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const setWakeLock = async () => {
|
||||
try {
|
||||
@@ -659,6 +719,8 @@
|
||||
eventTarget.addEventListener('chat', chatEventHandler);
|
||||
eventTarget.addEventListener('chat:finish', chatFinishHandler);
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
return async () => {
|
||||
await stopAllAudio();
|
||||
|
||||
@@ -668,6 +730,8 @@
|
||||
eventTarget.removeEventListener('chat', chatEventHandler);
|
||||
eventTarget.removeEventListener('chat:finish', chatFinishHandler);
|
||||
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
|
||||
audioAbortController.abort();
|
||||
await tick();
|
||||
|
||||
@@ -687,6 +751,9 @@
|
||||
eventTarget.removeEventListener('chat:start', chatStartHandler);
|
||||
eventTarget.removeEventListener('chat', chatEventHandler);
|
||||
eventTarget.removeEventListener('chat:finish', chatFinishHandler);
|
||||
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
|
||||
audioAbortController.abort();
|
||||
|
||||
await tick();
|
||||
@@ -882,19 +949,42 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pb-2 w-full">
|
||||
<div>
|
||||
<div class="flex flex-col items-center gap-4 pb-4 w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="z-10"
|
||||
on:click={() => {
|
||||
if (assistantSpeaking) {
|
||||
stopAllAudio();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="line-clamp-1 text-sm font-medium">
|
||||
{#if loading}
|
||||
{$i18n.t('Thinking...')}
|
||||
{:else if muted}
|
||||
{$i18n.t('Muted')}
|
||||
{:else if assistantSpeaking}
|
||||
{$i18n.t('Tap to interrupt')}
|
||||
{:else}
|
||||
{$i18n.t('Listening...')}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center justify-center gap-4 z-10">
|
||||
{#if camera}
|
||||
<VideoInputMenu
|
||||
devices={videoInputDevices}
|
||||
on:change={async (e) => {
|
||||
console.log(e.detail);
|
||||
selectedVideoInputDeviceId = e.detail;
|
||||
localStorage.setItem('selectedVideoInputDeviceId', e.detail);
|
||||
await stopVideoStream();
|
||||
await startVideoStream();
|
||||
}}
|
||||
>
|
||||
<button class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900" type="button">
|
||||
<button class="p-3 rounded-full bg-gray-50 dark:bg-gray-900" type="button">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -912,7 +1002,7 @@
|
||||
{:else}
|
||||
<Tooltip content={$i18n.t('Camera')}>
|
||||
<button
|
||||
class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900"
|
||||
class="p-3 rounded-full bg-gray-50 dark:bg-gray-900"
|
||||
type="button"
|
||||
on:click={async () => {
|
||||
await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
@@ -941,32 +1031,63 @@
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (assistantSpeaking) {
|
||||
stopAllAudio();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" line-clamp-1 text-sm font-medium">
|
||||
{#if loading}
|
||||
{$i18n.t('Thinking...')}
|
||||
{:else if assistantSpeaking}
|
||||
{$i18n.t('Tap to interrupt')}
|
||||
<Tooltip content={muted ? $i18n.t('Unmute') + ' (M)' : $i18n.t('Mute') + ' (M)'}>
|
||||
<button
|
||||
class="p-3 rounded-full transition-colors duration-200 {muted
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-gray-50 dark:bg-gray-900'}"
|
||||
type="button"
|
||||
aria-label={muted ? $i18n.t('Unmute') : $i18n.t('Mute')}
|
||||
on:click={toggleMute}
|
||||
>
|
||||
{#if muted}
|
||||
<!-- Mic Off icon -->
|
||||
<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 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="3"
|
||||
x2="21"
|
||||
y2="21"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
{$i18n.t('Listening...')}
|
||||
<!-- Mic On icon -->
|
||||
<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 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class=" p-3 rounded-full bg-gray-50 dark:bg-gray-900"
|
||||
class="p-3 rounded-full bg-gray-50 dark:bg-gray-900"
|
||||
on:click={async () => {
|
||||
await stopAudioStream();
|
||||
await stopVideoStream();
|
||||
|
||||
@@ -238,7 +238,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const mineTypes = ['audio/webm; codecs=opus', 'audio/mp4'];
|
||||
const mineTypes = [
|
||||
'audio/webm; codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg; codecs=opus',
|
||||
'audio/mp4',
|
||||
'audio/wav'
|
||||
];
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: mineTypes.find((type) => MediaRecorder.isTypeSupported(type))
|
||||
|
||||
@@ -140,18 +140,49 @@
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const element = document.getElementById('messages-container');
|
||||
element.scrollTop = element.scrollHeight;
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
|
||||
// Follow-up scroll to account for content-visibility: auto re-layouts
|
||||
requestAnimationFrame(() => {
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const scrollToTop = async () => {
|
||||
messagesCount = null;
|
||||
buildMessages();
|
||||
await tick();
|
||||
if (messages.length > 0) {
|
||||
const firstMessageEl = document.getElementById(`message-${messages[0].id}`);
|
||||
if (firstMessageEl) {
|
||||
firstMessageEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateChat = async () => {
|
||||
if (!$temporaryChatEnabled) {
|
||||
history = history;
|
||||
await tick();
|
||||
await updateChatById(localStorage.token, chatId, {
|
||||
const res = await updateChatById(localStorage.token, chatId, {
|
||||
history: history,
|
||||
messages: messages
|
||||
});
|
||||
|
||||
// Refresh local message content from backend (e.g. re-derived via serialize_output)
|
||||
if (res?.chat?.history?.messages) {
|
||||
for (const [id, msg] of Object.entries(res.chat.history.messages)) {
|
||||
if (history.messages[id] && (msg as any).content) {
|
||||
history.messages[id].content = (msg as any).content;
|
||||
}
|
||||
}
|
||||
history = history;
|
||||
}
|
||||
|
||||
currentChatPage.set(1);
|
||||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
}
|
||||
@@ -305,7 +336,7 @@
|
||||
await updateChat();
|
||||
};
|
||||
|
||||
const editMessage = async (messageId, { content, files }, submit = true) => {
|
||||
const editMessage = async (messageId, { content, files, output = undefined }, submit = true) => {
|
||||
if ((selectedModels ?? []).filter((id) => id).length === 0) {
|
||||
toast.error($i18n.t('Model not selected'));
|
||||
return;
|
||||
@@ -349,7 +380,7 @@
|
||||
}
|
||||
} else {
|
||||
if (submit) {
|
||||
// New response message
|
||||
// New response message (Save As Copy)
|
||||
const responseMessageId = uuidv4();
|
||||
const message = history.messages[messageId];
|
||||
const parentId = message.parentId;
|
||||
@@ -361,6 +392,7 @@
|
||||
childrenIds: [],
|
||||
files: undefined,
|
||||
content: content,
|
||||
output: output ?? undefined,
|
||||
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
|
||||
};
|
||||
|
||||
@@ -380,6 +412,9 @@
|
||||
// Edit response message
|
||||
history.messages[messageId].originalContent = history.messages[messageId].content;
|
||||
history.messages[messageId].content = content;
|
||||
if (output !== undefined) {
|
||||
history.messages[messageId].output = output;
|
||||
}
|
||||
await updateChat();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
{/each}
|
||||
{#if citations.length > 3}
|
||||
<div
|
||||
class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-[8px] font-semibold text-gray-500 dark:text-gray-400"
|
||||
class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-[8px] font-semibold text-gray-500 dark:text-gray-400 whitespace-nowrap tracking-tighter"
|
||||
aria-hidden="true"
|
||||
>
|
||||
+{citations.length - Math.min(urlCitations.length, 3)}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { settings } from '$lib/stores';
|
||||
import { settings, config } from '$lib/stores';
|
||||
import { injectCsp } from '$lib/utils/csp';
|
||||
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
@@ -218,7 +219,7 @@
|
||||
false)
|
||||
? ' allow-same-origin'
|
||||
: ''}"
|
||||
srcdoc={document.document}
|
||||
srcdoc={injectCsp(document.document, $config?.ui?.iframe_csp ?? '')}
|
||||
title={$i18n.t('Content')}
|
||||
></iframe>
|
||||
{:else}
|
||||
|
||||
@@ -15,6 +15,57 @@
|
||||
import FloatingButtons from '../ContentRenderer/FloatingButtons.svelte';
|
||||
import { createMessagesList } from '$lib/utils';
|
||||
|
||||
/**
|
||||
* Extracts all top-level <details>...</details> blocks from content,
|
||||
* handling nested <details> via depth tracking.
|
||||
* Returns { detailsContent, plainContent }.
|
||||
*/
|
||||
const extractDetailsBlocks = (text) => {
|
||||
const blocks = [];
|
||||
let remaining = text;
|
||||
let result = '';
|
||||
const openTag = '<details';
|
||||
const closeTag = '</details>';
|
||||
|
||||
while (true) {
|
||||
const start = remaining.indexOf(openTag);
|
||||
if (start === -1) {
|
||||
result += remaining;
|
||||
break;
|
||||
}
|
||||
|
||||
result += remaining.slice(0, start);
|
||||
|
||||
// Find matching closing tag with depth tracking
|
||||
let depth = 1;
|
||||
let idx = start + openTag.length;
|
||||
while (depth > 0 && idx < remaining.length) {
|
||||
if (remaining.startsWith(openTag, idx)) {
|
||||
depth++;
|
||||
} else if (remaining.startsWith(closeTag, idx)) {
|
||||
depth--;
|
||||
}
|
||||
if (depth > 0) idx++;
|
||||
}
|
||||
|
||||
if (depth === 0) {
|
||||
const end = idx + closeTag.length;
|
||||
blocks.push(remaining.slice(start, end));
|
||||
remaining = remaining.slice(end);
|
||||
} else {
|
||||
// Unmatched opening tag, treat as plain text
|
||||
result += remaining.slice(start);
|
||||
remaining = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detailsContent: blocks.join('\n'),
|
||||
plainContent: result.trim()
|
||||
};
|
||||
};
|
||||
|
||||
export let id;
|
||||
export let content;
|
||||
|
||||
@@ -174,43 +225,55 @@
|
||||
</script>
|
||||
|
||||
<div bind:this={contentContainerElement}>
|
||||
<Markdown
|
||||
{id}
|
||||
content={model?.info?.meta?.capabilities?.citations == false
|
||||
? content.replace(/\s*(\[(?:\d+(?:#[^,\]\s]+)?(?:,\s*\d+(?:#[^,\]\s]+)?)*)\])+/g, '')
|
||||
: content}
|
||||
{model}
|
||||
{save}
|
||||
{preview}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
onUpdate={async (token) => {
|
||||
const { lang, text: code } = token;
|
||||
{#if $settings?.renderMarkdownInAssistantMessages ?? true}
|
||||
<Markdown
|
||||
{id}
|
||||
content={model?.info?.meta?.capabilities?.citations == false
|
||||
? content.replace(/\s*(\[(?:\d+(?:#[^,\]\s]+)?(?:,\s*\d+(?:#[^,\]\s]+)?)*)\])+/g, '')
|
||||
: content}
|
||||
{model}
|
||||
{save}
|
||||
{preview}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
onUpdate={async (token) => {
|
||||
const { lang, text: code } = token;
|
||||
|
||||
if (
|
||||
($settings?.detectArtifacts ?? true) &&
|
||||
(['html', 'svg'].includes(lang) || (lang === 'xml' && code.includes('svg'))) &&
|
||||
!$mobile &&
|
||||
$chatId
|
||||
) {
|
||||
await tick();
|
||||
showArtifacts.set(true);
|
||||
showControls.set(true);
|
||||
}
|
||||
}}
|
||||
onPreview={async (value) => {
|
||||
console.log('Preview', value);
|
||||
await artifactCode.set(value);
|
||||
await showControls.set(true);
|
||||
await showArtifacts.set(true);
|
||||
await showEmbeds.set(false);
|
||||
}}
|
||||
/>
|
||||
if (
|
||||
($settings?.detectArtifacts ?? true) &&
|
||||
(['html', 'svg'].includes(lang) || (lang === 'xml' && code.includes('svg'))) &&
|
||||
!$mobile &&
|
||||
$chatId
|
||||
) {
|
||||
await tick();
|
||||
showArtifacts.set(true);
|
||||
showControls.set(true);
|
||||
}
|
||||
}}
|
||||
onPreview={async (value) => {
|
||||
console.log('Preview', value);
|
||||
await artifactCode.set(value);
|
||||
await showControls.set(true);
|
||||
await showArtifacts.set(true);
|
||||
await showEmbeds.set(false);
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
{@const extracted = extractDetailsBlocks(content)}
|
||||
|
||||
{#if extracted.detailsContent}
|
||||
<!-- Render structural blocks (tool calls, reasoning, etc.) through Markdown -->
|
||||
<Markdown {id} content={extracted.detailsContent} {done} />
|
||||
{/if}
|
||||
{#if extracted.plainContent}
|
||||
<div class="whitespace-pre-wrap">{extracted.plainContent}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if floatingButtons}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
export let paragraphTag = 'p';
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
export let allowEmbeds = true;
|
||||
|
||||
export let sourceIds = [];
|
||||
|
||||
@@ -71,11 +72,17 @@
|
||||
};
|
||||
|
||||
const updateHandler = (content) => {
|
||||
if (content && !pendingUpdate) {
|
||||
pendingUpdate = requestAnimationFrame(() => {
|
||||
if (content) {
|
||||
if (done) {
|
||||
cancelAnimationFrame(pendingUpdate);
|
||||
pendingUpdate = null;
|
||||
parseTokens();
|
||||
});
|
||||
} else if (!pendingUpdate) {
|
||||
pendingUpdate = requestAnimationFrame(() => {
|
||||
pendingUpdate = null;
|
||||
parseTokens();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,6 +105,7 @@
|
||||
{editCodeBlock}
|
||||
{sourceIds}
|
||||
{topPadding}
|
||||
{allowEmbeds}
|
||||
{onTaskClick}
|
||||
{onSourceClick}
|
||||
{onSave}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
}> = [];
|
||||
|
||||
export let messageDone = true;
|
||||
export let allowEmbeds = true;
|
||||
|
||||
let open = $settings?.expandDetails ?? false;
|
||||
|
||||
@@ -51,6 +52,8 @@
|
||||
|
||||
// Collect all embeds from tool_calls tokens
|
||||
$: allEmbeds = (() => {
|
||||
if (!allowEmbeds) return [];
|
||||
|
||||
const result: Array<{ name: string; embed: string; args: string }> = [];
|
||||
for (const t of tokens) {
|
||||
if (t?.attributes?.type !== 'tool_calls') continue;
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let content: string;
|
||||
export let displayMode: boolean = false;
|
||||
@@ -29,5 +33,16 @@
|
||||
</script>
|
||||
|
||||
{#if renderToString}
|
||||
{@html renderToString(content, { displayMode, throwOnError: false })}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<svelte:element
|
||||
this={displayMode ? 'div' : 'span'}
|
||||
class="cursor-pointer"
|
||||
on:click={() => {
|
||||
copyToClipboard(content);
|
||||
toast.success($i18n.t('Copied to clipboard'));
|
||||
}}
|
||||
>
|
||||
{@html renderToString(content, { displayMode, throwOnError: false })}
|
||||
</svelte:element>
|
||||
{/if}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
export let allowEmbeds = true;
|
||||
|
||||
export let onSave: Function = () => {};
|
||||
export let onUpdate: Function = () => {};
|
||||
@@ -371,6 +372,7 @@
|
||||
id={`${id}-${tokenIdx}-detail-group`}
|
||||
tokens={token.items}
|
||||
messageDone={done}
|
||||
{allowEmbeds}
|
||||
>
|
||||
<div slot="content" class="space-y-1">
|
||||
{#each token.items as detailToken, detailIdx}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user