With memories disabled instance-wide, or for a user barred from the feature,
the background review still ran every interval turn: it spent a task-model
call drafting memory operations and only then failed at the write, because
the router's permission check rejected it. The review now checks the
'memories.enable' switch and re-checks the 'features.memories' permission
the same way the context-injection path already does, so a model whose memory
capability is on no longer triggers memory work that can never land.
The permission lookup costs a groups query, so it runs last, after the free
config and interval gates; those stay on every turn's hot path.
The due-timer executor rehydrates the owner from the database and now
verifies that the owner is still a user or an admin before entering the
chat completion pipeline, mirroring the check the scheduled-automation
executor already performs. A timer whose owner no longer qualifies is
recorded as an error instead of being run.
Web search via searchapi.io could come back empty or near-empty with no
hint of why: an invalid or expired API key turned into an empty result
set instead of an error, the google_news engine splits its results
between organic_results and top_stories and only the first block was
read, and google links came back as google.com/goto redirects the web
loader cannot fetch, so citations pointed at a redirect blob.
The search now reads both result blocks, asks google engines for
resolved destination links, raises on HTTP errors, carries a 30s request
timeout, skips result rows without a link, and logs the response body at
debug instead of dumping every search at info.
Fixes#30305
With the Redis websocket manager the shared model pool is a `RedisDict`, so resolving a model id fetches from Redis, and more than one of those fetches pulls the whole pool. Every chat request pays that latency and that traffic, and the cost grows with the number of models configured: at 120 models a request moves several hundred kilobytes to ask about models that have not changed since the last request.
The pool now keeps a per-worker cache of the hash and refetches it only when the signature key that `set()` already maintains changes. A cache may only trust a signature that describes the bytes it fetched, so every write invalidates the signature and no signature value is ever issued twice. Without a fresh token each time, a pool that flaps back to an earlier state returns to an earlier digest, so a reader that races the write keeps serving the old pool. `delete_many()` was a second hole in that: it updated a dead attribute and never touched the signature, so readers kept serving models it had already removed. A TTL cache would have been simpler, but it serves a pool it already knows may be stale and costs the same single round trip this check costs.
Replaying the real access pattern, a request that finds the pool unchanged moves about 1 KB no matter how many models are configured, over the same number of round trips as before, except after a write that leaves no signature, which holds reads at two commands until the next non-empty `set()` writes one. A request that first sees a changed pool costs one extra command, and so does rewriting the pool. Values stay cached serialized, so `items()` and `values()` still decode on every call, and each worker holds the whole pool in memory.
Long conversations get progressively more expensive to stream into. Every message-level write reloads the entire chat JSON, walks every string in it for a null-byte sanitisation pass and rewrites the whole column, and reading a single message loads and validates the whole chat too. The websocket event emitter reads and then writes, so one message event round-trips the full conversation twice to change a few hundred bytes.
Reading one message now selects only history.messages out of the JSON column and indexes it in Python, so the rest of the chat is neither loaded nor model-validated. Writing one message now sanitises only what is actually entering the chat, plus the title column that is mirrored off the blob, instead of re-walking a conversation that was already sanitised when it was written. Legacy rows are still healed on read by get_chat_by_id, which is unchanged.
One behaviour change: chats written before the null-byte sanitisation existed can still hold null bytes in the stored JSON. Those rows used to be rewritten clean as a side effect of any message write, and are now cleaned when the chat is read instead. The visible consequence is that the message write and delete endpoints echo back the updated chat, and for such a legacy row that echo now carries the raw null bytes rather than stripped ones, until the next read of that chat heals it. A GET of the chat is unaffected, and title, the one text column that PostgreSQL cannot store a null byte in, is still sanitised on every write.
Measured on SQLite with a 10.8 MB chat (3000 messages): a single-message upsert is 227 ms before the branch and 173 ms after, and the legacy single-message read is 135 ms before and 79 ms after. The whole-blob scan the delete path used to run costs 5.40 ms on a 1.8 MB chat and 38.79 ms on the 10.8 MB one, and is gone.
Ref https://github.com/open-webui/open-webui/issues/28169
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
Every client in a note re-broadcasts each update it receives, with a full content snapshot attached, and the server appends each echo to the document log and writes the note again. Traffic and note writes therefore scale with the number of people who have the note open: every extra participant adds one more full echo of every keystroke.
Remote updates are now applied with the `'server'` origin the state path already uses, which the local listener ignores, so the echo stops. The echo did carry one thing worth keeping: the receiving client holds the merged document, which the sender had not seen yet, so each receiver now sends a content-only message once the edits settle, debounced 500ms, and flushes a pending one when the editor is torn down. The backend accepts an update message with no `update` field for that case, where it previously raised and dropped the save.
Replaying keystrokes at 120ms with a real Yjs document, socket messages fall 48.8% with two clients, 65.6% with three and 79.2% with five, with byte counts tracking the same on notes up to 50KB. Server-side update appends drop by a factor of the client count. The cost is one snapshot upload per receiving client per typing pause, which the server's own debounce then collapses into a single extra note write however many people are watching. A snapshot has to come from a client because the server cannot rebuild the markdown, HTML and JSON shape the note record stores. It also moves the merge 500ms later than the echo delivered it, so if two edits cross on the wire and every editor then loses its connection and closes inside that window, the note keeps what it was last sent and one of the two edits is lost. A client still connected at teardown flushes its pending snapshot, and any other editor left in the note closes the gap.
The remote chat-image fetch now asks for an identity-encoded response and skips one that comes back content-encoded.
An image whose host stores and echoes a `Content-Encoding` regardless of what the client asks for (an S3 or MinIO object uploaded with that metadata) is no longer inlined; the message is forwarded with the original URL instead, the same way an unreachable image already behaves.
* fix: correct recurrence rule parsing for schedules and calendar events
An automation set to repeat a limited number of times, say ten or a hundred, was treated as a one-shot and reported no repeat interval, because any count whose digits began with a one matched a text check for the one-shot case. The scheduler already answers that question correctly by asking the rule for its next two occurrences, so the text check is gone and the count is read as the number it is.
A recurrence rule that carries its start date on the same line as the repeat text kept that date when the automation was parsed, so the schedule ran from whatever date the rule happened to carry and ignored the start the user picked. The filter that drops the start date now splits the rule on any whitespace, the same way the rule parser itself does, so both agree on where one part of the rule ends and the next begins.
The same mismatch on the calendar path anchored a recurring event to the date inside its rule, so occurrences showed up before the event had begun and at the wrong time of day. That filter splits the rule the same way now, and the series starts at the event's own start.
All three come from one place, recurrence rules being matched and cut as text. Rules written across several lines, which is what the schedule and calendar editors produce, behave exactly as before.
* refac: name rrule token vars parts to match calendar.py
The native edit_image tool fails with "400: [ERROR: Error loading image]" whenever the model hands it an absolute URL for an image Open WebUI already stores. Such a URL is treated as local only when its host string matches the incoming request's host exactly, so a default-port form, a container name or any host the model composed itself falls through to an outbound HTTP fetch instead. That fetch asks /api/v1/files/{id}/content without a session, gets a 401, and the user sees the generic 400.
Match the file URL on its path and let the existing local branch resolve it. Fetching that endpoint over the network can never succeed for a local or a remote instance, because it requires an authenticated user, so the host comparison only decided which way the request failed. Access control is unchanged: the local branch still goes through get_file_content_by_id, which enforces owner, admin or shared access.
Fixes#29220
Typing a message, uploading a file or toggling web search in a chat started from the home page was lost on reload: the input came back empty and the uploaded files were gone for good.
Drafts are kept per chat in sessionStorage, but the key was built from the route prop, which stays empty for these chats because creating the chat only swaps the URL with history.replaceState and never re-runs the route load. Drafts were written under the new-chat key while a reload of /c/<id> read the per-chat key. Falling back to the active chat id lines the two up, the same fallback loadChat already uses for this reason.
The Placeholder submit handler also cleared the bare key rather than the current one, which left a stale draft behind once a chat's messages had all been deleted.
Verified against the base commit on a local instance: on base the draft lands under chat-input and is lost on reload, with the change it lands under chat-input-<id> and both the text and the uploaded file come back. Image attachments stay excluded from drafts by design.
The model selection half of #29760 has a different cause and is not fixed here.
Refs #29760
Retiring a model leaves every chat that used it stuck. The chat still stores the old model id, so it loads with nothing usable selected and refuses to send until the user picks a replacement by hand, in every old conversation. There is no admin-side way to move those chats across.
Loading a chat now drops model ids that no longer exist, and an empty selection falls into the fallback this function already applies to new chats: the user's default model, then the admin-configured default, then the first available model. A chat that still has one live model keeps it. The filter runs before the single-model permission clamp so a restricted user keeps a live model the chat already has instead of losing it to the clamp.
The model list cannot distinguish a retired model from one whose connection is momentarily unreachable, so during such an outage a chat pinned to that connection will open on the fallback model and record it when saved. Models defined in the workspace are unaffected, since they stay in the list while their connection is down.
Staan rejects any count other than its fixed page size of 10, so every search 400ed with the default result count of 3. The API always returns 10 results; the local results[:count] slice already applies the configured count, so the request parameter is simply dropped.
When a model calls display_file, write_file or replace_file_content with a relative path, Open Terminal resolves it against the session working directory and returns the absolute path, but the event sent to the browser carried the raw argument instead. The file panel matches that string against the file browser root, a relative path never matches, so the preview never opens, the panel jumps to the root and the session working directory is rewritten to the root. With the root turned off (OPEN_TERMINAL_FILE_BROWSER_ROOT=filesystem) there is nothing to clamp to and the relative string is sent to the terminal as the new working directory, moving it silently. The tool call itself succeeds either way, so the failure only shows up as a panel that will not open the file the model just wrote.
Both events now carry the path from the tool result and fall back to the argument when the result cannot be read, which is what build_terminal_file_tool_result already does for the chat file attachment. The same one-line rule is applied to the direct tool server path in the frontend, where the browser runs the tool itself and the write_file branch beside it was already correct.
Checked against a live Open Terminal: relative arguments now emit the absolute path, absolute ones are unchanged, non-existent files and inline displays still emit nothing, unreadable or error results still fall back to the argument, and run_command is untouched.
Related to #30051