0.12.5 (#33371)
Some checks failed
Python CI / Ruff Format (3.11) (push) Has been cancelled
Python CI / Ruff Format (3.12) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true USE_CUDA_VER=cu126 free_disk:true name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Has been cancelled
Frontend Build / Format & Build (push) Has been cancelled
Frontend Build / Unit Tests (push) Has been cancelled
Release to PyPI / release (push) Has been cancelled
Release / publish (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda suffix:-cuda]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:cuda126 suffix:-cuda126]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:main suffix:]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:ollama suffix:-ollama]) (push) Has been cancelled
Create and publish Docker images with specific build args / merge (map[name:slim suffix:-slim]) (push) Has been cancelled
Create and publish Docker images with specific build args / notify-helm-charts (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (, main) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda, cuda) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-cuda126, cuda126) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-ollama, ollama) (push) Has been cancelled
Create and publish Docker images with specific build args / copy-to-dockerhub (-slim, slim) (push) Has been cancelled

* feat: rename a chat from the menu next to its title (#33177)

* feat: slider control for number valves with input type range (#33179)

* i18n: add pt-BR translations for newly added UI items and consistency pass (#33173)

New **pt-BR** translations for items introduced in the latest releases, plus a consistency/quality pass across existing strings (grammar, tone, capitalization, pluralization). Placeholders and hotkeys preserved. No logic changes.

* fix: the search dialog understands and suggests its tag:, folder:, pinned:, shared: and archived: filters again (#33181)

* feat: unassign a keyboard shortcut from Settings > Keyboard (#33185)

* fix: scrolling on the Keyboard settings tab no longer moves the whole Settings window (#33187)

* refac

* refac

* refac

* fix(channels): keep large pastes as text when Shift is held (#33190)

* i18n: Update Catalan translations.json file (#33215)

* i18n: Update catalan translation.json

* Update translation.json

* i18n: Update catalan translation.json

* fix: Read Aloud uses Kokoro.js when it is picked under Settings > Audio (#33211)

* fix: a folder's item count leaves out hidden files while they are hidden (#33213)

* fix: a model's Chats tab in Analytics follows the User group filter (#33207)

* fix: the Local source filter on Workspace > Knowledge lists knowledge bases made with Create (#33199)

* fix: a refused sharing change is undone in the Access dialog (#33209)

* fix: a refused sharing change is undone in the Access dialog

* fix: a refused sharing change in the model editor shows the server's message

* refac

* fix: adding another tool server connection starts with empty Headers (#33201)

* refac

* i18n: fill empty it-IT strings (#33216)

* fix: tool calls make chats compact too early and inflate prompt token counts (#33218)

Since a recent refactor on dev, the prompt and completion token counts of a reply that used tools added up every model call in the loop, even though each later call already re-sends the whole conversation. A reply whose context was 50k tokens showed 90k, the context gauge read over 100% and the next message triggered compaction long before the chat reached the threshold. Prompt and completion tokens show the latest call again, while total tokens and cost still add up across all calls.

* fix: typing in a note does nothing after switching Formatting on or off (#33222)

After flipping the Formatting switch in a note's menu, the editor stopped taking any input: typed text never appeared and nothing was saved, until the page was reloaded. The editor that comes back after the switch was still handed the one it replaced, and with the current editor library that old one now fails as soon as it is touched, so the new editor never connected to the note. Typing works right away after the switch now, and the text is saved and survives a reload.

* fix: replies without reported token usage show an empty Info button (#33220)

A recent dev change marks a reply as having incomplete usage when the provider reports no token counts, so the session analytics can tell measured replies apart. The chat then treated that marker as usage and showed the Info button under every such reply, with a tooltip that only said "incomplete: true". The Info button now only appears when the reply carries actual usage data, while the stored marker stays as it is for analytics.

* fix: saving Admin Settings > Interface updates the generation switches shown in Settings > Interface (#33197)

* fix: the search window only offers locations that fit the chosen content type (#33183)

* refac

* refac

* fix: Backspace while typing with a Japanese or other IME commits or duplicates the composing text (#33245)

Pressing Backspace inside an unconfirmed IME composition in the chat input ended the composition, so the next keystrokes started a new one and the text appeared twice. This started in 0.12.4, when the editor library picked up a release that treats every deletion as a Backspace key press, including deletions the IME makes inside a composition, and the editor's Backspace handling then disturbs the composition. Pinning that library to the last release without this behaviour restores normal IME editing.

Fixes #33223

* i18n: fill missing translations in 48 languages (#33252)

* fix(a11y): name the action column in the users and feedback tables (#33257)

* fix: a failed ComfyUI run is reported as a successful image generation with no images (#33237)

* fix: sub-agents cannot be given a photo attached in the chat (#33244)

When the model handed a photo from the chat to a sub-agent, the call failed with "file_ids not attached or unavailable" and no sub-agent started. Text files and documents worked, only photos were refused. Photos attached in the chat are now accepted and reach the sub-agent as an image, the same way they reach the model in a normal chat. Files that were never attached to the chat are still refused.

Fixes #33241

* fix: Kokoro.js falls back to WASM when the browser has WebGPU but no GPU adapter (#33233)

* fix(a11y): name the usage heatmap cells for screen readers (#33255)

* refac

* fix(a11y): keep chat messages in a list and make each message's name a heading (#33261)

Co-authored-by: ThreadWeaver <valandkayla@gmail.com>

* refac

* fix: foreground sub-agent loses its partial output at the tool-call limit (#33269)

When a foreground sub-agent stopped at the tool-call limit, the parent model got only the error, so everything the sub-agent had written before the limit was lost, while a background sub-agent in the same state reported it as partial output. A foreground sub-agent now returns the error followed by its partial output, the same way the background report does. The error in both modes is now the plain message, such as "Tool-call limit reached (30 iterations).", instead of raw internal data around it.

Fixes #33267

* refac: refuse a compressed body when fetching a remote URL (#33271)

Remote fetches now ask for an uncompressed response: web pages, documents, remote images, profile pictures from a login provider and webhook deliveries. A response that comes back compressed anyway is refused, except for a webhook reply, which is only logged and is left unread instead.

A page or file whose host echoes a stored `Content-Encoding` regardless of what the client asks for (an S3 or MinIO object uploaded with that metadata) is no longer read, and a profile picture that arrives that way falls back to the default avatar.

* refac

* refac

* refac

* fix: message ignores the Web Search toggle after switching models (#33275)

After changing the model or adding a second one to compare, the Web Search toggle showed the new default but the next message still went out with the previous model's setting. A model without Web Search as a default feature could search, and one with it did not. The message now follows what the toggle shows.

Fixes #33265

* fix: chat stops with no answer when an MCP server request fails mid-chat (#33272)

When an MCP tool server could not be reached for a tool call (server restarted, connection reset by a proxy or load balancer, a request timing out), the whole chat was cancelled as if the user had pressed stop, and the log only said "Task was cancelled!". Now only that tool call fails, the model gets the connection error as the tool result and the reply finishes, and the underlying connection error is logged. The MCP SDK cancels whatever task opened the session when a single request fails, so each MCP session now runs in its own task instead of the chat's.

Fixes #33268

* refac

* i18n: make Indonesian terminology consistent and translate a missed string (#33303)

Use "berkas" instead of "File" (108 existing uses vs 14), "Tambah" instead of
"Menambahkan" for the Add Files / Add Memory buttons (matching the other
"Add ..." actions), and "Pengaturan" instead of "Setelan". Also translate
"Enter certificate path", which was still in English.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: a removed webhook carried over from before v0.11 stays removed (#33295)

* fix: a calendar event that ends after midnight keeps its end time (#33290)

* fix: Playground Controls reach the model (#33286)

* fix: labels in workspace search, Skills and the terminal file browser stay English (#33308)

The workspace search showed its result labels such as "Personal chat" and "Personal note" in English for every interface language, and some of its own labels never reached the translation files. Several labels, prompts and banners in the Skills list, the skill editor and the terminal file browser were also hard-coded in English. They now follow the interface language. Folder and channel names in search results stay as the user typed them, except a folder named exactly like one of the five fixed labels, which shows translated as well, since the search results do not tell the two apart.

Fixes #33304
Fixes #33305

* fix: a new notification target becomes the default after the last one was removed (#33284)

* feat: notify automation owners when a run fails (#33331)

When a scheduled automation failed (for example because its model was deleted), the owner could only see it as an error in the run history, so they found out much later when the results stopped showing up. Automation run failed is now one of the automatic events users can pick for their notification targets under Settings > Notifications, and every failed run, scheduled or started by hand, sends the automation name and the error to those targets. In Admin Settings > General > Events, webhooks set to Specific users or groups now receive these failures when the owner is among those users or groups, and webhooks set to System events only stop receiving them.

Closes #33228

* feat: allow negative values for the Relevance Threshold setting (#33330)

The Relevance Threshold field in the Documents settings refused negative values, so admins whose reranker returns raw scores (which can go below zero) could not set a useful threshold, and a negative RAG_RELEVANCE_THRESHOLD made the whole Documents settings page impossible to save while hybrid search was on. The server already stores and applies negative thresholds; only the field blocked them. The field now takes negative values in the same 0.01 steps as before, its help text says they are valid for rerankers that return raw scores outside the 0 to 1 range, and the hover hint asking for a value between 0 and 1 is gone. Nothing changes for 0 and above, and 0 still means no threshold.

Closes #29888

* feat: let admins exclude routes like health checks from OpenTelemetry metrics (#33329)

With OpenTelemetry metrics on, every request is counted in the HTTP request and duration metrics, so Kubernetes liveness and readiness probes on `/health` and `/health/db` fill the time series database and dashboards with noise. These metrics now skip any request whose URL matches `OTEL_PYTHON_FASTAPI_EXCLUDED_URLS` (or `OTEL_PYTHON_EXCLUDED_URLS` as a fallback), the standard OpenTelemetry variable that traces already honour. It takes a comma-separated list of regular expressions such as `/health,/_app/immutable/.*`, each matched anywhere in the URL, so `/health` also covers `/health/db`. One pattern now drops the same requests from traces and metrics alike. Nothing changes when the variable is unset.

Closes #31609

* feat: moving a chat into or out of a folder keeps its last activity date (#33327)

Filing an old chat into a folder made it look recently active: it jumped to the top of the sidebar under Today, and a batch of old chats sorted into folders ended up ordered by when each was moved. Moving a chat into or out of a folder now keeps its last activity date, so it is sorted and grouped by when it was really last active. Moving your own chat still clears its unread dot as before, and when the owner of a shared folder reorganizes your chat within it, the chat no longer turns unread for you.

Closes #30362

* feat: show ComfyUI images saved by the Save Animated WEBP and Save Animated PNG nodes (#33325)

A ComfyUI workflow that ends in the core Save Animated WEBP or Save Animated PNG node finished on the ComfyUI side, but nothing showed up in the chat, because Open WebUI only picked up images from the Save Image, Save Image (Advanced) and Preview Image nodes. Images from both animated nodes now show up in the chat too, as the original animated file, for image generation and image editing.

Closes #33013

* feat: choose the Mistral OCR model and pass extra OCR request options (#33333)

Mistral OCR always sent the mistral-ocr-latest model with no other request options, so admins could not stay on an older OCR model or send the model name a Mistral-compatible endpoint such as Azure AI Foundry expects. A new Parameters field under Admin Panel > Settings > Documents, shown when Mistral OCR is the extraction engine and also available as the MISTRAL_OCR_PARAMS environment variable, takes a JSON object that is merged into every OCR request: {"model": "mistral-ocr-2512"} replaces the default model, and any other field is sent to the OCR endpoint as given. It works the same with and without the Use Base64 switch, and when it is left empty the request is unchanged.

Closes #19707

* perf: kb_exec reads the full text of the whole knowledge base on every call (#33332)

Every kb_exec command (ls, cat, grep and the rest) and the knowledge base sync check listed the knowledge base by loading each file's full extracted text, even though only names, folders, sizes and hashes were used. On large knowledge bases every call took seconds, held hundreds of MB of memory and blocked the server for other users while it ran. The listing now reads only those fields, and file text is still loaded per file when a command needs it, as before. Output is byte-identical.

5,005 files (5,000 of 50 KB in 50 folders plus 5 of 10 MB, 292 MB of text), median of 3 calls, Postgres 17:

| kb_exec command | before | after | peak memory before | after |
|---|---|---|---|---|
| `ls` | 2.34 s | 0.20 s | 874 MB | 243 MB |
| `ls folder_000/` | 2.15 s | 0.20 s | 873 MB | 243 MB |
| `cat folder_000/doc_000_001.md` | 1.07 s | 0.17 s | 855 MB | 243 MB |
| `grep -n needle folder_000/` | 1.24 s | 0.31 s | 871 MB | 243 MB |
| `grep -c needle large_0.md` | 1.19 s | 0.22 s | 874 MB | 306 MB |

On SQLite the same calls drop from 0.50-1.03 s to 0.16-0.29 s.

Fixes #33322

* refac

* refac

* fix: readers see a remove button on a shared note's files (#33281)

Since a recent dev change, a reader opening a shared note sees the remove button on every file in the note, although clicking it does nothing. The editor was created editable and only switched to read-only afterwards, so the files were drawn as if the reader could edit. It now starts read-only for readers, and their files show without the button.

Fixes #33279

* refac

* refac

* fix: folder owners can rename a chat from the menu next to its title (#33339)

* refac

* refac

* refac

* fix: member's chat loses its pin when the shared folder owner moves it (#33342)

When the owner of a shared folder moved a chat a member created to another folder inside the shared folder, the chat lost the member's pin and dropped out of their Pinned list. The owner's move now keeps the member's pin, just as it already keeps the chat's unread status for them. Moving your own chat into a folder still unpins it, as before, so it shows up in that folder.

Fixes #33341

* refac

* fix: a cleared Embedding Batch Size no longer breaks file processing (#33288)

* refac

* fix(a11y): focus outlines on the tab rows are no longer cut off at the corners (#33363)

* fix(a11y): menu items, submenu items included, move with the arrow keys, Home and End (#33359)

* fix(a11y): Tab from a note's title reaches the Generate title button (#33354)

* fix(a11y): every field in the calendar event window has a name (#33361)

* refac

* fix(a11y): the model picker's highlighted row follows Tab, so only one row is outlined (#33367)

* fix(a11y): opening a filter list with the mouse also moves focus to its options (#33352)

* fix(a11y): Escape then Tab leaves the Compare revision editors instead of closing the window (#33357)

* refac

* fix(a11y): a model picker opened in a window takes keyboard focus, and focus stays in the window after it closes (#33365)

* refac

* feat: keep tool images away from models with Vision turned off (#33345)

When a tool returned an image, for example Open Terminal reading a PNG, a native tool or an MCP screenshot tool, the image was sent to the model even when the model has Vision turned off. A provider serving a text-only model rejects that request, so the reply failed with an error like "At most 0 image(s) may be provided in one prompt", and every later message in the chat failed the same way. With Vision off the model now gets a short "[image]" note in place of each tool image, including images already saved in the chat, so stuck chats work again. The images stay saved, so they reach the model again once Vision is turned on.

Fixes #32720

* refac

* refac

* fix: a calendar event that ends at midnight shows on one day in Month view (#33292)

* refac

* fix: deleting, renaming or adding a file keeps expanded folders listed in the Files tab (#33195)

* perf: knowledge base changes slow down as the knowledge base grows (#33335)

Adding, updating or removing knowledge base files through the API sent back the metadata of every file in the knowledge base, and so did renaming a knowledge base or changing its access. These calls got slower as the knowledge base grew, so bulk ingestion through the API lost speed with every batch. Adding or updating files now returns only those files, while removing a file, renaming or changing access returns an empty files list. API clients that read the files list from these responses now get only the affected files; the full list stays available, paginated, from `GET /api/v1/knowledge/{id}/files`. Timings with a local mock embedding server that answers instantly, median of 5 runs (at 1,000 files the per-file work of a batch still hides the difference):

| Files already in the knowledge base | Call | Before | After | Response size before | Response size after |
|---|---|---|---|---|---|
| 1,000 | batch add of 100 files | 519 ms | 528 ms | 437 KB | 39 KB |
| 10,000 | batch add of 100 files | 679 ms | 476 ms | 4.0 MB | 39 KB |
| 10,000 | single file add | 184 ms | 36 ms | 4.0 MB | 0.6 KB |

Closes #31397

* fix: the Available Tools window lists an OpenAPI tool server's tools (#33205)

* refac

* refac

* refac

* chore: i18n

* docs: start 0.12.5 changelog (#33188)

* docs: start 0.12.5 changelog

* docs: add session explorer and token count fix to 0.12.5 changelog

* docs: add provider details and latest fixes to 0.12.5 changelog

* docs: add embedding prefixes and new fixes to 0.12.5 changelog

* docs: update 0.12.5 changelog date

* docs: add generated images in terminals to 0.12.5 changelog

* docs: add latest fixes, accessibility and translations to 0.12.5 changelog

* docs: add model selection changes to 0.12.5 changelog

* docs: extend 0.12.5 accessibility entry

* docs: update model switch entry in 0.12.5 changelog

* docs: add sub-agent fix and security notice to 0.12.5 changelog

* docs: add terminal start timeout to 0.12.5 changelog

* docs: add note pages, presence and MCP fix to 0.12.5 changelog

* docs: add calendar and webhook fixes to 0.12.5 changelog

* docs: add Playground, notification and translation fixes to 0.12.5 changelog

* docs: link rename request in 0.12.5 changelog

* docs: add latest features and fixes to 0.12.5 changelog

* docs: add API key management and reconnect fix to 0.12.5 changelog

* docs: add shared folder pin fix to 0.12.5 changelog

* docs: add visibility controls and batch size fix to 0.12.5 changelog

* docs: extend 0.12.5 accessibility entry

* docs: add tool image, Stop button and thread fixes to 0.12.5 changelog

* docs: add display_file, chat loading and other fixes to 0.12.5 changelog

* docs: add faster chat search to 0.12.5 changelog

* chore: bump

* refac

* fix: arrow keys skip menu entries that appear after the menu opens (#33375)

Menus only let the arrow keys, Home and End reach the entries that were there the moment they opened. Entries that show up afterwards, such as the file, note, knowledge and chat lists in the message box's More menu or the tags in the chat actions menu, could only be reached with Tab or the mouse. These entries can now be reached with the arrow keys as soon as they appear, and screen readers announce them as menu items like the rest.

Fixes #33374

---------

Co-authored-by: G30 <50341825+silentoplayz@users.noreply.github.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: Aleix Dorca <aleixdorca@mac.com>
Co-authored-by: Loris Batacchi <DANIELE.GIOVANARDI@GMAIL.COM>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: ThreadWeaver <valandkayla@gmail.com>
Co-authored-by: Mad-jr <120295540+Madleyym@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Tim Baek 2026-10-29 18:05:24 +09:00 • committed by GitHub
commit f7ac7ee109
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
206 changed files with 18396 additions and 3497 deletions

View file

@ -46,6 +46,11 @@ ENABLE_FUNCTIONS=true
# Set false to disable external tools and terminals, including personal direct connections.
ENABLE_TOOL_SERVERS=true
# Terminal metadata requests may start a container. Default: 60 seconds.
# When unset, inherits an explicitly configured AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA.
# Empty or 0 disables the timeout. Other tool-server metadata still defaults to 10 seconds.
# AIOHTTP_CLIENT_TIMEOUT_TERMINAL=60
# WARNING: * trusts forwarded headers from every connection. Use only behind a trusted
# proxy that sanitizes these headers and prevents direct access to the backend.
# Otherwise, set this to the IP addresses or networks of your trusted reverse proxies.

View file

@ -5,6 +5,87 @@ 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.12.5] - 2026-10-29
### Added
- 🧭 **Analytics session explorer.** Administrators can now open a Sessions tab in Admin Analytics that lists each chat with its tokens, responses, errors and time span, filters them by search, model, token range or sub-agents, and shows a chat's saved replies and tool calls with their timing and outcome, as long as "ENABLE_ADMIN_CHAT_ACCESS" is on. [Commit](https://github.com/open-webui/open-webui/commit/7b303cf496f22d665ff1f1013a2308de0d74a7a4)
- 📑 **Pages in notes.** Notes can now hold several pages and subpages, which you can add, rename, reorder, move under another page, download, link to and delete from the page tabs, and search results and the model now work with notes page by page. [Commit](https://github.com/open-webui/open-webui/commit/b34885efc639c10cbf06c35cd2d4ff6055e36b45)
- 👀 **Live presence in notes.** A note now shows who else has it open, and clicking a person jumps to the page and spot they are working on. [Commit](https://github.com/open-webui/open-webui/commit/e3ad014fb250c663a124ada0046c4017b85aabd6)
- ✏️ **Rename from chat menu.** The menu next to an open chat's title now has Rename, which turns the title at the top of the page into a text box, so a chat can be renamed without finding it in the sidebar first. [#33177](https://github.com/open-webui/open-webui/pull/33177), [#33176](https://github.com/open-webui/open-webui/discussions/33176), [#33246](https://github.com/open-webui/open-webui/issues/33246), [#33339](https://github.com/open-webui/open-webui/pull/33339), [#33338](https://github.com/open-webui/open-webui/issues/33338), [Commit](https://github.com/open-webui/open-webui/commit/551b03ff90bddaa2bbdac49d76d2bab61367e895), [Commit](https://github.com/open-webui/open-webui/commit/f517e528d2f2865c6e730966343eef6c02c064b8)
- 🖼️ **Generated images in terminals.** When a terminal is selected in a chat, generated and edited images are now also saved to its working folder, so the model can keep working on them with the terminal's tools. [Commit](https://github.com/open-webui/open-webui/commit/b420726ff1f04dff07a2877511b30ebe7b9887bb)
- 🗃️ **Models show chat files.** Models can now show a file attached in the chat or stored in an attached knowledge base right in the reply with the built-in display_file tool, which until now only worked for terminal files. [#32471](https://github.com/open-webui/open-webui/issues/32471), [Commit](https://github.com/open-webui/open-webui/commit/f2cdbbebe3cb40aab77516515198c7fa6c4818b7), [Commit](https://github.com/open-webui/open-webui/commit/ef29fb12f1f641bf1d83b434a37298846d211deb)
- 💾 **Chat model choice saved.** Changing the model in a chat is now saved with the chat right away, so the chat reopens with that model even when no message was sent after the change. [Commit](https://github.com/open-webui/open-webui/commit/3aaecdd0e99f6407187732f2fb117841ce2cc013)
- ⌨️ **Unassign single shortcuts.** Settings > Keyboard now shows a small x beside every assigned shortcut that unassigns only that one, so a shortcut such as Regenerate Response on Ctrl+R or Cmd+R can be switched off while all others keep working. [#33185](https://github.com/open-webui/open-webui/pull/33185), [#31463](https://github.com/open-webui/open-webui/discussions/31463), [Commit](https://github.com/open-webui/open-webui/commit/9fef07de3ae8c16b8f53e46b456c144bd739a8a2)
- 🎚️ **Slider for number Valves.** Plugin authors can now show a number Valve or UserValve as a slider with a number box beside it, whose ends come from the field's minimum and maximum, so users see the allowed range while picking a value. [#33179](https://github.com/open-webui/open-webui/pull/33179), [#32045](https://github.com/open-webui/open-webui/discussions/32045), [Commit](https://github.com/open-webui/open-webui/commit/9ed317dc90c7a7fda96a6de68491648341d27ca2)
- 📇 **Provider details for models.** Administrators editing a model now see a Provider details section with the input and output types, context window, maximum output and supported parameters that the provider reports for it. [Commit](https://github.com/open-webui/open-webui/commit/18996b03bafa46e80bf91a1798ed1b8e0c86081c)
- 🧲 **Embedding prefixes in settings.** Administrators can now set the query and content prefixes that some embedding models need under Admin Settings > Documents, where they could only be set with the "RAG_EMBEDDING_QUERY_PREFIX" and "RAG_EMBEDDING_CONTENT_PREFIX" environment variables. [Commit](https://github.com/open-webui/open-webui/commit/4a10c093ab1dc9d195c4bcd662c22aec97bba22c)
- ⏱️ **Terminal start timeout.** Open WebUI now waits up to 60 seconds by default when it asks a terminal for its skills, AGENTS.md file or uploads, so a terminal that is still starting can answer, with "AIOHTTP_CLIENT_TIMEOUT_TERMINAL" to change the limit and a message naming the terminal when it still times out. [Commit](https://github.com/open-webui/open-webui/commit/ab133dba2183491d7c22229ae2b3b30cacf020d4)
- 🚨 **Automation failure notifications.** Automation run failed is now an event users can pick for their notification targets under Settings > Notifications, so the owner hears about every failed run with the automation's name and error. [#33331](https://github.com/open-webui/open-webui/pull/33331), [#33228](https://github.com/open-webui/open-webui/issues/33228), [Commit](https://github.com/open-webui/open-webui/commit/e49ff5df64a1523b80fce8ef03637953265c4355)
- ⚡ **Faster knowledge base commands.** Knowledge base commands such as ls, cat and grep and the knowledge base sync check now list files without loading every file's full text, so on large knowledge bases each call is up to ten times faster and uses far less memory. [#33332](https://github.com/open-webui/open-webui/pull/33332), [#33322](https://github.com/open-webui/open-webui/issues/33322), [Commit](https://github.com/open-webui/open-webui/commit/517f824eb42615b203eb5c1f8779c268aa829960)
- 🔭 **Faster chat search.** Searching your chats, including from the Reference Chats menu and the model's chat search tool, no longer loads every matching chat in full, so results come back faster on large chat histories, and a result's message preview in the Reference Chats menu now loads when you hover it. [Commit](https://github.com/open-webui/open-webui/commit/44a9d499db851f7c6243ada8bfddb9d5b419f473)
- 📦 **Batched document embedding.** Large documents are now embedded and saved in batches of 1000 chunks by default, set with "RAG_EMBEDDING_BUFFER_SIZE", so processing a very large file holds far less in memory at once. [Commit](https://github.com/open-webui/open-webui/commit/58536b65038dc7ff17d02dd78c60cc71ce110f92)
- 🧾 **Mistral OCR model choice.** Administrators can now pick the Mistral OCR model and send extra request options through a Parameters field in Admin Settings > Documents or "MISTRAL_OCR_PARAMS", for example to stay on an older OCR model or to match what an Azure AI Foundry endpoint expects. [#33333](https://github.com/open-webui/open-webui/pull/33333), [#19707](https://github.com/open-webui/open-webui/issues/19707), [Commit](https://github.com/open-webui/open-webui/commit/f1f0a194aef3d029f8243f7e7ac553d492283c07)
- 📉 **Negative relevance threshold.** The Relevance Threshold in Admin Settings > Documents now takes negative values for rerankers that return raw scores below zero, where the field refused them and a negative "RAG_RELEVANCE_THRESHOLD" kept the page from saving while hybrid search was on. [#33330](https://github.com/open-webui/open-webui/pull/33330), [#29888](https://github.com/open-webui/open-webui/issues/29888), [Commit](https://github.com/open-webui/open-webui/commit/34ef99dbfb0815fbca10d3faea2da7c50f29e19f)
- 🎞️ **Animated ComfyUI images.** Images from ComfyUI's Save Animated WEBP and Save Animated PNG nodes now show up in the chat as the animated file, for both image generation and image editing. [#33325](https://github.com/open-webui/open-webui/pull/33325), [#33013](https://github.com/open-webui/open-webui/issues/33013), [Commit](https://github.com/open-webui/open-webui/commit/cfe745b00c272679db7a6082749f6f7a6c5f7e36)
- 📡 **Metrics skip health checks.** OpenTelemetry metrics now leave out requests whose URL matches "OTEL_PYTHON_FASTAPI_EXCLUDED_URLS" or "OTEL_PYTHON_EXCLUDED_URLS", so health checks and probes no longer fill the request metrics, the same way traces already skip them. [#33329](https://github.com/open-webui/open-webui/pull/33329), [#31609](https://github.com/open-webui/open-webui/issues/31609), [Commit](https://github.com/open-webui/open-webui/commit/76b7be67fce6375244e4b00f0fdda5fb896e056e)
- 🗝️ **Admins manage user API keys.** Administrators can now rotate or delete a user's API key in the Edit User dialog, and a rotated key is shown once so it can be copied. [#32774](https://github.com/open-webui/open-webui/issues/32774), [Commit](https://github.com/open-webui/open-webui/commit/360d81dfe8f68f8fe7e26702cfcdc8921941da1a), [Commit](https://github.com/open-webui/open-webui/commit/fdaa240d441de02a687f3ea069409f5ff7851a74)
- 👓 **More hideable chat controls.** Administrators can now also hide the selected tools, skills, filters, web search, image generation and code interpreter shown in the chat input, through Visible controls in Admin Settings > Interface or "UI_VISIBILITY". [#32408](https://github.com/open-webui/open-webui/issues/32408), [Commit](https://github.com/open-webui/open-webui/commit/839759cb179e9924149648a896cd9bab15037d6e)
- ♿ **Improved UI accessibility.** Screen reader and keyboard users can now move through a chat message by message with each message's name as a heading, move through menus and submenus with the arrow keys, Home and End, reach a note's Generate title button and the options of a filter list opened with the mouse, leave the Compare revision editors with Escape and Tab, and hear names for every field of the calendar event window, the last column of the Users and Feedback tables and each day in the Settings > Usage heatmap, while the model picker outlines only one row, takes keyboard focus when opened in a window and leaves focus in that window when it closes, and focus outlines on tab rows are no longer cut off.
- 🔄 **General improvements.** Various improvements were implemented across the application to enhance performance, stability, and security.
- 🌐 **Translation updates.** Translations for Portuguese (Brazil), Catalan, Italian, Azerbaijani, Basque, Bengali, Bosnian, Bulgarian, Cebuano, Croatian, Czech, Danish, Dutch, Estonian, Faroese, Filipino, Galician, Georgian, German, Greek, Hebrew, Hindi, Hungarian, Indonesian, Irish, Japanese, Kabyle, Korean, Latvian, Lithuanian, Malay, Norwegian Bokmål, Persian, Polish, Punjabi, Romanian, Russian, Serbian, Slovak, Slovenian, Spanish (Spain), Swedish, Tamil, Thai, Tibetan, Turkish, Turkmen, Ukrainian, Urdu, Uyghur and Vietnamese were enhanced and expanded.
### Fixed
- 🛡️ **Security Advisory**: This release contains **security fixes**. We **strongly recommend updating** all deployments **as soon as possible** to have the latest security fixes in place. These fixes are **not listed in this changelog**; we will publish the security advisories for them immediately following CVE assignment. [Advisories](https://github.com/open-webui/open-webui/security)
- 🔎 **Search dialog filters restored.** The search dialog again understands and suggests the "tag:", "folder:", "pinned:", "shared:" and "archived:" filters, where adding one emptied the results and archived chats could not be found at all. [#33181](https://github.com/open-webui/open-webui/pull/33181), [#32518](https://github.com/open-webui/open-webui/issues/32518), [Commit](https://github.com/open-webui/open-webui/commit/ec83763f1373801e7d0ed2f39e0b53f58a75156c)
- 📍 **Search locations by type.** The Location filter in the search window now lists only folders for Chats and only channels for Messages, and switching the content type clears a location that no longer fits, where such a pick returned an empty list. [#33183](https://github.com/open-webui/open-webui/pull/33183), [#33182](https://github.com/open-webui/open-webui/issues/33182), [Commit](https://github.com/open-webui/open-webui/commit/0f28fa402354aa4f5526dbd238da1ec567582466)
- 🧮 **Inflated streamed token counts.** Token counts for a reply are no longer inflated when the provider reports its usage again on every streamed chunk, where every report was added up, so the usage shown for the reply and in Admin Analytics now matches the provider's final count. [#32052](https://github.com/open-webui/open-webui/issues/32052), [#29064](https://github.com/open-webui/open-webui/issues/29064), [#28528](https://github.com/open-webui/open-webui/issues/28528), [Commit](https://github.com/open-webui/open-webui/commit/7b303cf496f22d665ff1f1013a2308de0d74a7a4)
- 📝 **Note typing after Formatting.** Typing in a note now works right after switching Formatting on or off in the note's menu, where the editor stopped taking input and saving until the page was reloaded. [#33222](https://github.com/open-webui/open-webui/pull/33222), [Commit](https://github.com/open-webui/open-webui/commit/67875f7656d3b531f4b482ebc5171b68b5972613)
- 🎙️ **Note recordings kept.** A recording in a note now stays in the note when the connection drops while it is being transcribed, for example when a laptop goes to sleep. [#33240](https://github.com/open-webui/open-webui/issues/33240), [Commit](https://github.com/open-webui/open-webui/commit/3b8205505b871b92dd23e010b61f51a02d515a92)
- 🀄 **IME typing with Backspace.** Pressing Backspace while composing text with a Japanese or other input method in the chat input no longer ends the composition or makes the text appear twice, which started in 0.12.4. [#33245](https://github.com/open-webui/open-webui/pull/33245), [#33223](https://github.com/open-webui/open-webui/issues/33223), [Commit](https://github.com/open-webui/open-webui/commit/a88fbc41a197f46ca148bf6dee16ad9ebafa2245)
- 🤖 **Photos for sub-agents.** Models can now hand a photo attached in the chat to a sub-agent, where the hand-off failed with "file_ids not attached or unavailable" and no sub-agent started. [#33244](https://github.com/open-webui/open-webui/pull/33244), [#33241](https://github.com/open-webui/open-webui/issues/33241), [Commit](https://github.com/open-webui/open-webui/commit/7a76e9fed6710faf2fd9c2162fce96bade6a8bee)
- 🪆 **Sub-agent partial output kept.** A foreground sub-agent that stops at the tool-call limit now hands the model its partial output along with the error, where everything it had written was lost, and the error now reads as a plain message in both foreground and background. [#33269](https://github.com/open-webui/open-webui/pull/33269), [#33267](https://github.com/open-webui/open-webui/issues/33267), [Commit](https://github.com/open-webui/open-webui/commit/e77204559bd92b43b7320ab128aac768e9be23db)
- 🔗 **MCP failures keep the chat.** A tool call to an MCP server that cannot be reached, for example after a restart or a dropped connection, now fails on its own and the reply still finishes, where the whole chat stopped as if you had pressed stop. [#33272](https://github.com/open-webui/open-webui/pull/33272), [#33268](https://github.com/open-webui/open-webui/issues/33268), [Commit](https://github.com/open-webui/open-webui/commit/2c64ab5487bd76e87c0306f6295acd7c710445e7)
- 🔃 **Cut-off replies stay finished.** A reply cut off by a server restart no longer goes back to the loading dots when the connection comes back later. [#33277](https://github.com/open-webui/open-webui/issues/33277), [Commit](https://github.com/open-webui/open-webui/commit/7b4d4b51f6d40587a6de94bc09cfd145f71dea2f)
- 🐢 **Slow chat loads.** Opening a chat on a slow connection or switching chats quickly now shows the chat you opened, where a slower earlier load could replace it with an old copy and the next message forked the chat. [#33180](https://github.com/open-webui/open-webui/issues/33180), [Commit](https://github.com/open-webui/open-webui/commit/193cf225f377ccc021b5128b80e5370e93895b2f)
- 👁️ **Tool images with Vision off.** Models with Vision turned off now get a short "[image]" note in place of each image a tool returns, where the reply failed with an error and every later message in that chat failed the same way. [#33345](https://github.com/open-webui/open-webui/pull/33345), [#32720](https://github.com/open-webui/open-webui/issues/32720), [Commit](https://github.com/open-webui/open-webui/commit/63ca4237eb43d64911af9b4a0e3ab7501c25560f)
- ✋ **Stop button on others' replies.** In a chat shared with Allow replies, the Stop button no longer shows on someone else's running reply, where it showed but did nothing. [#33343](https://github.com/open-webui/open-webui/issues/33343), [Commit](https://github.com/open-webui/open-webui/commit/d6610adaa9ffebaa4fb2058d1116debc4e47d5f4)
- 🧷 **Refused sharing changes undone.** The Access dialog of a prompt, skill, tool, note or knowledge base now goes back to the saved sharing when the server refuses a change, where it kept showing the refused change as if it were saved, and the model editor now shows the server's message for such a refusal. [#33209](https://github.com/open-webui/open-webui/pull/33209), [#33208](https://github.com/open-webui/open-webui/issues/33208), [Commit](https://github.com/open-webui/open-webui/commit/db146f945987cabb8c4fd6f43c97d140968a0a7a)
- 🔌 **Tool server Headers reset.** Adding another connection in External Tool Servers now starts with empty Headers, where it kept the previous connection's Headers and saved them on the new one. [#33201](https://github.com/open-webui/open-webui/pull/33201), [#33200](https://github.com/open-webui/open-webui/issues/33200), [Commit](https://github.com/open-webui/open-webui/commit/8889e8d6affd487b9dae5df7f0c971fc30ae58b3)
- 🧰 **OpenAPI tools listed.** The Available Tools window in a chat now lists the tools of an OpenAPI tool server, where it said "No tools found". [#33205](https://github.com/open-webui/open-webui/pull/33205), [#33204](https://github.com/open-webui/open-webui/issues/33204), [Commit](https://github.com/open-webui/open-webui/commit/722f71f2cca9ad5cf7def62452eeddfea9248e2e)
- 🎛️ **Generation switches update.** Saving Title, Follow-Up, Tags or Autocomplete Generation in Admin Settings > Interface now updates the matching switches in Settings > Interface and autocomplete in the chat input right away, where they kept the old setting until a reload. [#33197](https://github.com/open-webui/open-webui/pull/33197), [#33196](https://github.com/open-webui/open-webui/issues/33196), [Commit](https://github.com/open-webui/open-webui/commit/7e07575d0f0ec2a0c43caa8a8e6d5c9129c18a8d)
- 🗂️ **Local knowledge filter.** Picking Local in the source filter on Workspace > Knowledge now lists knowledge bases made with Create, where it showed "No knowledge found". [#33199](https://github.com/open-webui/open-webui/pull/33199), [#33198](https://github.com/open-webui/open-webui/issues/33198), [Commit](https://github.com/open-webui/open-webui/commit/6635a45df49eef925fc2c4949c445d197a81e6e9)
- 🪣 **Large S3 Vectors deletes.** Removing a file or knowledge base with more than 500 chunks from S3 Vectors now works, where the delete request went over the service's limit of 500 keys and failed. [Commit](https://github.com/open-webui/open-webui/commit/58536b65038dc7ff17d02dd78c60cc71ce110f92)
- 🔢 **Empty embedding batch size.** Clearing the Embedding Batch Size in Admin Settings > Documents now saves the default of 1, where files uploaded afterwards failed to process. [#33288](https://github.com/open-webui/open-webui/pull/33288), [#33287](https://github.com/open-webui/open-webui/issues/33287), [Commit](https://github.com/open-webui/open-webui/commit/893dbc9bd8930cdb3be82f38c795366f3b8ca9b0)
- 🔊 **Read Aloud with Kokoro.js.** Read Aloud now speaks with the chosen Kokoro voice when Kokoro.js (Browser) is picked in Settings > Audio, where it used the browser's built-in voice while the server's speech engine was Web API. [#33211](https://github.com/open-webui/open-webui/pull/33211), [#33210](https://github.com/open-webui/open-webui/issues/33210), [Commit](https://github.com/open-webui/open-webui/commit/1a425bd641e96ca3e981a9a712e10596c019331c)
- 🔉 **Kokoro.js without a GPU.** Kokoro.js (Browser) now runs on the CPU in browsers that offer WebGPU but have no GPU available, where its voices never finished loading and Read Aloud kept spinning without speaking. [#33233](https://github.com/open-webui/open-webui/pull/33233), [#33232](https://github.com/open-webui/open-webui/issues/33232), [Commit](https://github.com/open-webui/open-webui/commit/bafe181330dc8cb3c54afd78cc3e2901a448ea3f)
- 🎨 **ComfyUI failures reported.** A ComfyUI workflow that fails, for example when the GPU runs out of memory, now shows an error and tells the model the image could not be made, where it was reported as a successful generation with no images. [#33237](https://github.com/open-webui/open-webui/pull/33237), [#33236](https://github.com/open-webui/open-webui/issues/33236), [Commit](https://github.com/open-webui/open-webui/commit/d1ae9e75e00ab4401454a76c8e782dca193ff3e2)
- 🖌️ **Responses API images shown.** Images that a model creates through the Responses API, such as Gemini image models through OpenRouter, now show in the chat, where they were saved but not displayed. [#33320](https://github.com/open-webui/open-webui/issues/33320), [Commit](https://github.com/open-webui/open-webui/commit/ef29fb12f1f641bf1d83b434a37298846d211deb)
- 🛝 **Playground Controls applied.** Settings chosen under Controls in the Playground, such as Temperature and Seed, now reach Ollama models, where they were dropped and every run gave a different reply. [#33286](https://github.com/open-webui/open-webui/pull/33286), [#33285](https://github.com/open-webui/open-webui/issues/33285), [Commit](https://github.com/open-webui/open-webui/commit/f58fdf9b9531c27d18f449485eccd985490a86c0)
- 📋 **Shift paste in channels.** Holding Shift while pasting long text into a channel message or thread reply now keeps it as text when Paste Large Text as File is on, as it already did in chats. [#33190](https://github.com/open-webui/open-webui/pull/33190), [#33189](https://github.com/open-webui/open-webui/issues/33189), [Commit](https://github.com/open-webui/open-webui/commit/eb2bcba2154a27d76f1bad6deda78d1ff90ec2fc)
- 🧵 **Thread replies while loading.** A thread reply or model answer that arrives while the thread is still loading now shows up in full, and a reply no longer shows twice. [#33346](https://github.com/open-webui/open-webui/issues/33346), [Commit](https://github.com/open-webui/open-webui/commit/d14d8788af6626bffc3717c7ef5bcbc4f441a5cc), [Commit](https://github.com/open-webui/open-webui/commit/be8ebed78428bde77a369c5cb4ed1c1bca720592)
- 👥 **Analytics model chats by group.** A model's Chats tab in Admin Analytics now follows the User group filter, where it listed every user's chats with that model. [#33207](https://github.com/open-webui/open-webui/pull/33207), [#33206](https://github.com/open-webui/open-webui/issues/33206), [Commit](https://github.com/open-webui/open-webui/commit/02821c50584193377da4e0320cf8a14652ab2327)
- 🙈 **Hidden files in folder counts.** The item count under a folder in a chat's terminal file browser now leaves out hidden files while Show Hidden Files is off. [#33213](https://github.com/open-webui/open-webui/pull/33213), [#33212](https://github.com/open-webui/open-webui/issues/33212), [Commit](https://github.com/open-webui/open-webui/commit/4cb37dd539f94f2d80fae9f1448f317553430872)
- 📁 **Expanded folders kept.** Deleting, renaming or adding a file in a chat's Files tab now keeps expanded folders listed, where every expanded folder came up empty. [#33195](https://github.com/open-webui/open-webui/pull/33195), [#33194](https://github.com/open-webui/open-webui/issues/33194), [Commit](https://github.com/open-webui/open-webui/commit/208e71d3861ae2e8ea68d52bd58a07926b278553)
- 📌 **Pins kept in shared folders.** A member's chat now keeps its pin when the owner of a shared folder moves it to another folder inside that folder, where it dropped out of the member's Pinned list. [#33342](https://github.com/open-webui/open-webui/pull/33342), [#33341](https://github.com/open-webui/open-webui/issues/33341), [Commit](https://github.com/open-webui/open-webui/commit/226dca18cdd79cac04bc36888ea0f834762b90cc)
- 🌙 **Overnight calendar events.** A calendar event that ends after midnight, such as 8:00 PM to 1:00 AM, now keeps its end time on the next day, where saving dropped it and stored the event as ending at its start time. [#33290](https://github.com/open-webui/open-webui/pull/33290), [#33289](https://github.com/open-webui/open-webui/issues/33289), [Commit](https://github.com/open-webui/open-webui/commit/a8e8e7e792201346553c6e911c3f81306f23d60f)
- 🗓️ **Midnight calendar events.** A calendar event that ends at midnight now shows only on its own day in Month view, where it also showed on the next day. [#33292](https://github.com/open-webui/open-webui/pull/33292), [#33291](https://github.com/open-webui/open-webui/issues/33291), [Commit](https://github.com/open-webui/open-webui/commit/1b92d9848488dd2402686c2da25b16c6382c1520)
- 🪝 **Old webhook removable.** A notification webhook carried over from before v0.11 now stays removed from Settings > Notifications, where it came right back after Remove. [#33295](https://github.com/open-webui/open-webui/pull/33295), [#33294](https://github.com/open-webui/open-webui/issues/33294), [Commit](https://github.com/open-webui/open-webui/commit/0ac55c8e7205aed414c1f265ca8e7047a772c08d)
- 🔔 **New default notification target.** A notification target added after removing the last one now becomes the default, where it never did. [#33284](https://github.com/open-webui/open-webui/pull/33284), [#33283](https://github.com/open-webui/open-webui/issues/33283), [Commit](https://github.com/open-webui/open-webui/commit/77398eba3126eb6b3614e8bc868a268aff668e44)
- 🗣️ **Translated search and Skills labels.** Labels in the workspace search, the Skills list, the skill editor and the terminal file browser now follow the interface language, where several always showed in English. [#33308](https://github.com/open-webui/open-webui/pull/33308), [#33304](https://github.com/open-webui/open-webui/issues/33304), [#33305](https://github.com/open-webui/open-webui/issues/33305), [Commit](https://github.com/open-webui/open-webui/commit/fcc9284139f180aec64f58839a9f83dd5dfa4f68)
- 📜 **Keyboard tab scrolling.** Scrolling on the Settings > Keyboard tab no longer moves the whole Settings window. [#33187](https://github.com/open-webui/open-webui/pull/33187), [#33186](https://github.com/open-webui/open-webui/issues/33186), [Commit](https://github.com/open-webui/open-webui/commit/22dd3e2be1dadddfeeccb616f8e45df352f87e72)
### Changed
- 🔀 **Model switch keeps your picks.** Switching models in a chat now applies the new model's default tools, skills, filters and features while keeping the ones you turned on or off yourself, where every switch cleared your picks, and chats with several models now get the default tools and skills of every selected model. [Commit](https://github.com/open-webui/open-webui/commit/3aaecdd0e99f6407187732f2fb117841ce2cc013), [Commit](https://github.com/open-webui/open-webui/commit/1c572c49bc5103b78c812e069872015d7fcb7391)
- 📣 **Model mentions switch chats.** Sending a message to a model picked with @ now switches the chat to that model, where the mention stayed above the input and the chat kept its own model. [Commit](https://github.com/open-webui/open-webui/commit/3aaecdd0e99f6407187732f2fb117841ce2cc013)
- 🔁 **Regenerate uses current models.** Regenerating a response in a chat with several models now uses the model currently picked for that column, where it reused the model that first wrote the response. [Commit](https://github.com/open-webui/open-webui/commit/3aaecdd0e99f6407187732f2fb117841ce2cc013)
- 📎 **Note attachments in pages.** Files attached to a note now sit inside the page where they were added, and the attached files of existing notes move to the end of the note's first page. [Commit](https://github.com/open-webui/open-webui/commit/b34885efc639c10cbf06c35cd2d4ff6055e36b45)
- 📂 **Folder moves keep chat dates.** Moving a chat into or out of a folder now keeps its last activity date, so it stays sorted by when it was really last active, where it jumped to the top of the sidebar under Today. [#33327](https://github.com/open-webui/open-webui/pull/33327), [#30362](https://github.com/open-webui/open-webui/issues/30362), [Commit](https://github.com/open-webui/open-webui/commit/40a9fba81f3640b7d3c9eafd78e7cd19d8de7674)
- 📮 **Automation failure webhooks.** Admin event webhooks set to System events only no longer receive automation run failures, which now go to webhooks set to specific users or groups that include the automation's owner. [#33331](https://github.com/open-webui/open-webui/pull/33331), [Commit](https://github.com/open-webui/open-webui/commit/e49ff5df64a1523b80fce8ef03637953265c4355)
- 📃 **Knowledge base API responses trimmed.** Adding, updating or removing knowledge base files and renaming or sharing a knowledge base through the API now return only the affected files or an empty list, so these calls no longer slow down as the knowledge base grows, and the full list stays available from the paginated files endpoint. [#33335](https://github.com/open-webui/open-webui/pull/33335), [#31397](https://github.com/open-webui/open-webui/issues/31397), [Commit](https://github.com/open-webui/open-webui/commit/a59cab0aa990c32dc3b633cb9eda01384b683115)
## [0.12.4] - 2026-10-25
### Added

View file

@ -965,6 +965,16 @@ MISTRAL_OCR_API_KEY = os.getenv('MISTRAL_OCR_API_KEY', '')
MISTRAL_OCR_USE_BASE64 = os.getenv('MISTRAL_OCR_USE_BASE64', 'False').lower() == 'true'
mistral_ocr_params = os.getenv('MISTRAL_OCR_PARAMS', '')
try:
mistral_ocr_params = JSONCodec.loads(mistral_ocr_params)
except JSONCodec.JSONDecodeError:
mistral_ocr_params = {}
if not isinstance(mistral_ocr_params, dict):
mistral_ocr_params = {}
MISTRAL_OCR_PARAMS = mistral_ocr_params
PADDLEOCR_VL_BASE_URL = os.getenv('PADDLEOCR_VL_BASE_URL', 'http://localhost:8080')
PADDLEOCR_VL_TOKEN = os.getenv('PADDLEOCR_VL_TOKEN', '')
@ -2991,6 +3001,7 @@ DEFAULT_CONFIG = {
'rag.mistral_ocr_api_base_url': MISTRAL_OCR_API_BASE_URL,
'rag.mistral_ocr_api_key': MISTRAL_OCR_API_KEY,
'rag.mistral_ocr_use_base64': MISTRAL_OCR_USE_BASE64,
'rag.mistral_ocr_params': MISTRAL_OCR_PARAMS,
'rag.paddleocr_vl_base_url': PADDLEOCR_VL_BASE_URL,
'rag.paddleocr_vl_token': PADDLEOCR_VL_TOKEN,
'rag.bypass_embedding_and_retrieval': BYPASS_EMBEDDING_AND_RETRIEVAL,
@ -3012,6 +3023,8 @@ DEFAULT_CONFIG = {
'rag.embedding_model': RAG_EMBEDDING_MODEL,
'rag.tokenizer_model': RAG_TOKENIZER_MODEL,
'rag.embedding_batch_size': RAG_EMBEDDING_BATCH_SIZE,
'rag.embedding_query_prefix': RAG_EMBEDDING_QUERY_PREFIX,
'rag.embedding_content_prefix': RAG_EMBEDDING_CONTENT_PREFIX,
'rag.enable_async_embedding': ENABLE_ASYNC_EMBEDDING,
'rag.embedding_concurrent_requests': RAG_EMBEDDING_CONCURRENT_REQUESTS,
'rag.reranking_engine': RAG_RERANKING_ENGINE,

View file

@ -99,6 +99,8 @@ class ERROR_MESSAGES(str, Enum):
INVALID_URL = 'The URL you provided is invalid. Please double-check and try again.'
COMPRESSED_RESPONSE = 'The server answered with a compressed response, which is not accepted.'
WEB_SEARCH_ERROR = 'Something went wrong while searching the web.'
OLLAMA_API_DISABLED = 'The Ollama API is disabled. Please enable it to use this feature.'

View file

@ -674,6 +674,23 @@ try:
except (ValueError, TypeError):
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
# Terminal requests can provision a container before returning metadata.
AIOHTTP_CLIENT_TIMEOUT_TERMINAL = os.getenv(
'AIOHTTP_CLIENT_TIMEOUT_TERMINAL',
os.getenv('AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA', '60'),
)
if AIOHTTP_CLIENT_TIMEOUT_TERMINAL == '':
AIOHTTP_CLIENT_TIMEOUT_TERMINAL = None
else:
try:
AIOHTTP_CLIENT_TIMEOUT_TERMINAL = int(AIOHTTP_CLIENT_TIMEOUT_TERMINAL)
except (ValueError, TypeError):
AIOHTTP_CLIENT_TIMEOUT_TERMINAL = 60
if AIOHTTP_CLIENT_TIMEOUT_TERMINAL is not None and AIOHTTP_CLIENT_TIMEOUT_TERMINAL <= 0:
AIOHTTP_CLIENT_TIMEOUT_TERMINAL = None
AIOHTTP_FILE_STREAM_CHUNK_SIZE = os.getenv('AIOHTTP_FILE_STREAM_CHUNK_SIZE', str(1024 * 1024))
try:
AIOHTTP_FILE_STREAM_CHUNK_SIZE = int(AIOHTTP_FILE_STREAM_CHUNK_SIZE)
@ -741,6 +758,15 @@ try:
except ValueError:
AIOHTTP_POOL_DNS_TTL = 300
# Target chunks buffered before insertion, rounded up to a whole embedding batch.
try:
RAG_EMBEDDING_BUFFER_SIZE = int(os.getenv('RAG_EMBEDDING_BUFFER_SIZE', '1000'))
if RAG_EMBEDDING_BUFFER_SIZE <= 0:
raise ValueError
except ValueError:
log.warning('RAG_EMBEDDING_BUFFER_SIZE must be a positive integer. Defaulting to 1000.')
RAG_EMBEDDING_BUFFER_SIZE = 1000
RAG_EMBEDDING_TIMEOUT = os.getenv('RAG_EMBEDDING_TIMEOUT', '')
if RAG_EMBEDDING_TIMEOUT == '':

View file

@ -705,6 +705,7 @@ NOTIFICATION_EVENTS = (
EVENTS.CHANNEL_MESSAGE.name,
EVENTS.CALENDAR_ALERT.name,
EVENTS.RETRIEVAL_CONTENT_PROCESS_FAILED.name,
EVENTS.AUTOMATION_RUN_FAILED.name,
)

View file

@ -1645,6 +1645,17 @@ async def chat_completion(
error_detail = None
try:
try:
if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'):
async with Chats.edit_message_output(metadata['chat_id'], metadata['message_id']) as message:
if message is not None:
response_meta = dict(message.get('meta') or {})
# Continuations retain the original start, including approval pauses.
# A historical response without a recorded start remains unknown.
if not metadata.get('assistant_message_id'):
response_meta['response_started_at'] = time.time()
response_meta.pop('response_ended_at', None)
message['meta'] = response_meta
message['done'] = False
if not metadata.get('direct'):
target = (
model_info
@ -1686,6 +1697,15 @@ async def chat_completion(
try:
async def emit_cancel_event():
if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'):
async with Chats.edit_message_output(
metadata['chat_id'], metadata['message_id']
) as message:
if message is not None:
response_meta = dict(message.get('meta') or {})
response_meta.setdefault('response_ended_at', time.time())
message['meta'] = response_meta
message['done'] = True
event_emitter = await get_event_emitter(metadata)
if event_emitter:
await event_emitter({'type': 'chat:tasks:cancel'})
@ -1711,6 +1731,7 @@ async def chat_completion(
'parentId': metadata.get('user_message_id'),
'error': {'content': error_detail},
'done': True,
'meta': {'response_ended_at': time.time()},
},
)
except Exception:
@ -1736,15 +1757,6 @@ async def chat_completion(
# 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() suppresses known transport teardown errors
# while still propagating real task cancellation.
try:

View file

@ -0,0 +1,605 @@
"""Read-only session analytics over existing chats and saved messages."""
import math
from open_webui.models.chat_messages import ChatMessage, _token_columns
from open_webui.models.chats import Chat
from open_webui.models.folders import Folder
from open_webui.models.groups import group_user_memberships
from open_webui.models.users import User
from open_webui.utils.session_records import safe_snippet
from sqlalchemy import (
ARRAY,
JSON,
Float,
Integer,
Text,
case,
cast,
distinct,
func,
literal,
or_,
select,
true,
union_all,
)
from sqlalchemy.ext.asyncio import AsyncSession
def json_text(db, column, *path):
if db.get_bind().dialect.name == 'sqlite':
return func.json_extract(column, '$' + ''.join(f'.{key}' for key in path))
return (
func.json_extract_path_text(column, *path)
if path
else column.op('#>>', return_type=Text)(cast(literal('{}'), ARRAY(Text)))
)
def json_type(db, column):
return func.json_type(column) if db.get_bind().dialect.name == 'sqlite' else func.json_typeof(column)
def message_filters(start_date=None, end_date=None):
conditions = []
if start_date is not None:
conditions.append(ChatMessage.created_at >= start_date)
if end_date is not None:
conditions.append(ChatMessage.created_at <= end_date)
return conditions
def chat_filters(db, group_id=None, query='', hide_subagents=False, model_id=None, start_date=None, end_date=None):
conditions = []
if group_id:
members = group_user_memberships([group_id], True)
conditions.append(Chat.user_id.in_(select(members.c.user_id)))
if query.strip():
# Literal search: '%' and '_' in titles are not wildcard operators.
term = '%' + query.strip().replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + '%'
conditions.append(
or_(
Chat.title.ilike(term, escape='\\'),
User.name.ilike(term, escape='\\'),
Folder.name.ilike(term, escape='\\'),
)
)
if hide_subagents:
conditions.append(func.coalesce(json_text(db, Chat.meta, 'type'), '') != 'subagent')
if model_id:
conditions.append(
select(ChatMessage.id)
.where(
ChatMessage.chat_id == Chat.id,
ChatMessage.model_id == model_id,
*message_filters(start_date, end_date),
)
.exists()
)
return conditions
def session_query(
db, *, start_date=None, end_date=None, group_id=None, query='', hide_subagents=False, model_id=None, chat_id=None
):
input_tokens, output_tokens = _token_columns(db.get_bind().dialect.name)
assistant = ChatMessage.role == 'assistant'
human = (ChatMessage.role == 'user') & (
func.coalesce(cast(json_text(db, ChatMessage.meta, 'internal'), Text), 'false').notin_(['true', '1'])
)
timestamp = case((ChatMessage.created_at > 0, ChatMessage.created_at))
recorded = assistant & (input_tokens.isnot(None) | output_tokens.isnot(None))
error = func.coalesce(cast(ChatMessage.error, Text), 'null').notin_(['null', '""', '{}', 'false'])
filters = message_filters(start_date, end_date)
if chat_id:
filters.append(ChatMessage.chat_id == chat_id)
# No transcript/output columns enter the list query, even when sorting by tokens.
cache_fields = {
'cache_read': func.coalesce(
json_text(db, ChatMessage.usage, 'cache_read_input_tokens'),
json_text(db, ChatMessage.usage, 'input_tokens_details', 'cached_tokens'),
json_text(db, ChatMessage.usage, 'prompt_tokens_details', 'cached_tokens'),
),
'cache_write': func.coalesce(
json_text(db, ChatMessage.usage, 'cache_creation_input_tokens'),
json_text(db, ChatMessage.usage, 'input_tokens_details', 'cache_write_tokens'),
json_text(db, ChatMessage.usage, 'prompt_tokens_details', 'cache_write_tokens'),
),
}
stats = (
select(
ChatMessage.chat_id.label('chat_id'),
*[func.sum(case((assistant, cast(field, Integer)))).label(key) for key, field in cache_fields.items()],
*[
func.sum(case((assistant & field.isnot(None), 1), else_=0)).label(key + '_responses')
for key, field in cache_fields.items()
],
func.count().label('messages'),
func.sum(case((assistant, 1), else_=0)).label('responses'),
func.sum(case((human, 1), else_=0)).label('prompts'),
func.sum(case((recorded, 1), else_=0)).label('recorded_responses'),
func.sum(
case(
(
assistant
& input_tokens.isnot(None)
& output_tokens.isnot(None)
& func.coalesce(ChatMessage.usage['incomplete'].as_boolean(), False).is_(False),
1,
),
else_=0,
)
).label('complete_responses'),
func.sum(
case((assistant, func.coalesce(input_tokens, 0) + func.coalesce(output_tokens, 0)), else_=0)
).label('tokens'),
func.sum(case((assistant & error, 1), else_=0)).label('errors'),
func.min(timestamp).label('started_at'),
func.max(timestamp).label('last_active'),
case((func.count(timestamp) == func.count(), func.max(timestamp) - func.min(timestamp))).label(
'span_seconds'
),
func.min(case((assistant, ChatMessage.model_id))).label('model_id'),
func.count(distinct(case((assistant, ChatMessage.model_id)))).label('model_count'),
)
.where(*filters)
.group_by(ChatMessage.chat_id)
.subquery()
)
statement = (
select(
Chat.id,
func.substr(Chat.title, 1, 200).label('title'),
User.id.label('user_id'),
User.name.label('user_name'),
Folder.name.label('folder_name'),
json_text(db, Chat.meta, 'type').label('kind'),
json_text(db, Chat.meta, 'forked_from').label('forked_from'),
*[column for column in stats.c if column.name != 'chat_id'],
)
.join(stats, stats.c.chat_id == Chat.id)
.outerjoin(User, User.id == Chat.user_id)
.outerjoin(Folder, Folder.id == Chat.folder_id)
)
return statement.where(*chat_filters(db, group_id, query, hide_subagents, model_id, start_date, end_date)), stats
def page_result(rows, limit):
items = [dict(row) for row in rows[:limit]]
for item in items:
if 'title' in item:
item['title'] = safe_snippet(item['title']) or ''
return {'items': items, 'has_more': len(rows) > limit}
TOKEN_RANGES = [(0, 1), (1, 1000), (1000, 10000), (10000, 100000), (100000, 1000000), (1000000, None)]
async def list_sessions(
db: AsyncSession,
*,
skip=0,
limit=25,
order_by='recent',
direction='desc',
include_summary=False,
token_min=None,
token_max=None,
**filters,
):
statement, stats = session_query(db, **filters)
order = {
'title': func.lower(Chat.title),
'user': func.lower(User.name),
'recent': stats.c.last_active,
'tokens': case((stats.c.recorded_responses > 0, stats.c.tokens)),
'responses': stats.c.responses,
'errors': stats.c.errors,
'duration': stats.c.span_seconds,
}[order_by]
order = order.asc() if direction == 'asc' else order.desc()
# The overview stays on the full search scope while its bars filter the table.
page_statement = statement
if token_min is not None or token_max is not None:
page_statement = page_statement.where(stats.c.recorded_responses > 0)
if token_min is not None:
page_statement = page_statement.where(stats.c.tokens >= token_min)
if token_max is not None:
page_statement = page_statement.where(stats.c.tokens < token_max)
rows = (
(await db.execute(page_statement.order_by(order.nulls_last(), Chat.id).offset(skip).limit(limit + 1)))
.mappings()
.all()
)
result = page_result(rows, limit)
if include_summary:
eligible = statement.subquery()
totals = (
(
await db.execute(
select(
func.count().label('sessions'),
func.coalesce(func.max(eligible.c.tokens), 0).label('max_tokens'),
func.count(case((eligible.c.recorded_responses == 0, 1))).label('unrecorded_sessions'),
*[
func.count(
case(
(
(eligible.c.recorded_responses > 0)
& (eligible.c.tokens >= low)
& ((eligible.c.tokens < high) if high is not None else true()),
1,
)
)
).label(f'token_range_{index}')
for index, (low, high) in enumerate(TOKEN_RANGES)
],
*[
func.coalesce(func.sum(eligible.c[key]), 0).label(key)
for key in (
'tokens',
'prompts',
'responses',
'errors',
'recorded_responses',
'complete_responses',
)
],
func.min(eligible.c.started_at).label('start'),
func.max(eligible.c.last_active).label('end'),
)
)
)
.mappings()
.one()
)
result['overview'] = dict(totals)
result['overview']['token_distribution'] = [
result['overview'].pop(f'token_range_{index}') for index in range(len(TOKEN_RANGES))
]
return result
async def session_messages(
db: AsyncSession, chat_id: str, *, start_date=None, end_date=None, group_id=None, skip=0, limit=25
):
statement, _ = session_query(db, chat_id=chat_id, start_date=start_date, end_date=end_date, group_id=group_id)
session = (await db.execute(statement)).mappings().first()
if session is None:
return None
input_tokens, output_tokens = _token_columns(db.get_bind().dialect.name)
content = case(
(json_type(db, ChatMessage.content).in_(['text', 'string']), json_text(db, ChatMessage.content)), else_=None
)
output_count = func.json_array_length(ChatMessage.output)
output_count = case((json_type(db, ChatMessage.output) == 'array', output_count), else_=0)
rows = (
(
await db.execute(
select(
ChatMessage.id,
ChatMessage.role,
ChatMessage.model_id,
ChatMessage.created_at,
ChatMessage.parent_id,
ChatMessage.done,
func.substr(content, 1, 320).label('preview'),
(func.length(content) > 320).label('preview_truncated'),
func.substr(json_text(db, ChatMessage.error), 1, 500).label('error'),
case(
(
input_tokens.isnot(None) | output_tokens.isnot(None),
func.coalesce(input_tokens, 0) + func.coalesce(output_tokens, 0),
)
).label('tokens'),
output_count.label('output_count'),
)
.where(ChatMessage.chat_id == chat_id, *message_filters(start_date, end_date))
.order_by(ChatMessage.created_at, ChatMessage.id)
.offset(skip)
.limit(limit + 1)
)
)
.mappings()
.all()
)
session = {**dict(session), 'title': safe_snippet(session['title']) or ''}
result = {'session': session, **page_result(rows, limit)}
for item in result['items']:
item['preview'] = safe_snippet(item['preview'])
item['error'] = safe_snippet(item['error'])
return result
def finite_number(value):
if isinstance(value, bool):
return None
try:
result = float(value)
return result if math.isfinite(result) and result >= 0 else None
except (ValueError, TypeError):
return None
def output_items(db):
empty = literal('[]') if db.get_bind().dialect.name == 'sqlite' else cast(literal('[]'), JSON)
output = case((json_type(db, ChatMessage.output) == 'array', ChatMessage.output), else_=empty)
if db.get_bind().dialect.name == 'sqlite':
items = func.json_each(output).table_valued('key', 'value', 'type', joins_implicitly=True)
index = cast(items.c.key, Integer)
value = case((items.c.type == 'object', items.c.value), else_=literal('{}'))
else:
items = func.json_array_elements(output).table_valued('value', with_ordinality='ordinality').render_derived()
index = items.c.ordinality - 1
value = items.c.value
def field(key):
return json_text(db, value, key)
return items, index, field
async def message_events(
db: AsyncSession, chat_id: str, message_id: str, *, group_id=None, start_date=None, end_date=None, skip=0, limit=20
):
# Guard the detail independently; IDs must not bypass the active group/date scope.
allowed = (
select(ChatMessage.id)
.join(Chat, Chat.id == ChatMessage.chat_id)
.where(
Chat.id == chat_id,
ChatMessage.id == message_id,
*message_filters(start_date, end_date),
*chat_filters(db, group_id=group_id),
)
)
if (await db.execute(allowed)).scalar_one_or_none() is None:
return None
items, index, field = output_items(db)
details = func.coalesce(
field('arguments'),
field('output'),
field('content'),
field('summary'),
field('text'),
field('code'),
field('error'),
)
statement = (
select(
index.label('index'),
func.substr(cast(field('type'), Text), 1, 80).label('type'),
func.substr(cast(field('name'), Text), 1, 160).label('name'),
func.substr(cast(field('call_id'), Text), 1, 200).label('call_id'),
func.substr(cast(field('status'), Text), 1, 64).label('status'),
func.substr(cast(field('error_type'), Text), 1, 80).label('error_type'),
func.substr(cast(field('started_at'), Text), 1, 64).label('started_at'),
func.substr(cast(field('ended_at'), Text), 1, 64).label('ended_at'),
func.substr(cast(field('duration'), Text), 1, 64).label('duration_seconds'),
func.substr(cast(details, Text), 1, 2000).label('details'),
(func.length(cast(details, Text)) > 2000).label('truncated'),
)
.select_from(ChatMessage)
.join(items, true())
.where(ChatMessage.id == message_id, ChatMessage.chat_id == chat_id)
)
rows = (await db.execute(statement.order_by(index).offset(skip).limit(limit + 1))).mappings().all()
result = page_result(rows, limit)
normalize_durations(result['items'])
for item in result['items']:
item['details'] = safe_snippet(item['details'])
item['truncated'] = True
return result
def normalize_durations(items):
for item in items:
for key in ('started_at', 'ended_at', 'duration_seconds'):
item[key] = finite_number(item[key])
if (
item['duration_seconds'] is None
and item['started_at'] is not None
and item['ended_at'] is not None
and item['ended_at'] >= item['started_at']
):
item['duration_seconds'] = item['ended_at'] - item['started_at']
async def session_trace(
db: AsyncSession,
chat_id: str,
*,
start_date=None,
end_date=None,
group_id=None,
skip=0,
limit=50,
view='all',
include_chart=False,
):
statement, _ = session_query(db, chat_id=chat_id, start_date=start_date, end_date=end_date, group_id=group_id)
session = (await db.execute(statement)).mappings().first()
if session is None:
return None
items, index, field = output_items(db)
filters = [ChatMessage.chat_id == chat_id, *message_filters(start_date, end_date)]
error = func.coalesce(cast(ChatMessage.error, Text), 'null').notin_(['null', '""', '{}', 'false'])
# Metadata only. Tool arguments/results are fetched separately when an item is selected.
messages = select(
ChatMessage.id.label('message_id'),
literal(-1).label('index'),
literal(None, Integer).label('result_index'),
ChatMessage.created_at,
ChatMessage.model_id,
literal('response').label('type'),
ChatMessage.role.label('name'),
literal(None, Text).label('call_id'),
case((error, 'failed'), (ChatMessage.done.is_(True), 'completed'), else_='in_progress').label('status'),
literal(None, Text).label('error_type'),
json_text(db, ChatMessage.meta, 'response_started_at').label('started_at'),
json_text(db, ChatMessage.meta, 'response_ended_at').label('ended_at'),
literal(None, Text).label('duration_seconds'),
).where(*filters, ChatMessage.role == 'assistant')
outputs = (
select(
ChatMessage.id.label('message_id'),
index.label('index'),
ChatMessage.created_at,
ChatMessage.model_id,
*[
func.substr(cast(field(key), Text), 1, size).label(key)
for key, size in [
('type', 80),
('name', 160),
('call_id', 200),
('status', 64),
('error_type', 80),
('started_at', 64),
('ended_at', 64),
]
],
func.substr(cast(field('duration'), Text), 1, 64).label('duration_seconds'),
)
.select_from(ChatMessage)
.join(items, true())
.where(*filters)
)
# Pair by saved message/call identity before pagination, never by adjacent rows.
saved = outputs.cte('saved_outputs')
pairs = (
select(
saved.c.message_id,
saved.c.call_id,
func.max(case((saved.c.type == 'function_call', saved.c.index))).label('call_index'),
func.max(case((saved.c.type == 'function_call_output', saved.c.index))).label('result_index'),
)
.where(saved.c.call_id.isnot(None), saved.c.call_id != '')
.group_by(saved.c.message_id, saved.c.call_id)
.subquery()
)
returned = saved.alias('saved_result')
is_call = saved.c.type == 'function_call'
outputs = (
select(
saved.c.message_id,
saved.c.index,
case((is_call, pairs.c.result_index)).label('result_index'),
*[
case((is_call, func.coalesce(returned.c[key], saved.c[key])), else_=saved.c[key]).label(key)
if key in {'status', 'error_type'}
else saved.c[key]
for key in saved.c.keys()
if key not in {'message_id', 'index'}
],
)
.select_from(saved)
.outerjoin(pairs, (pairs.c.message_id == saved.c.message_id) & (pairs.c.call_id == saved.c.call_id))
.outerjoin(returned, (returned.c.message_id == saved.c.message_id) & (returned.c.index == pairs.c.result_index))
.where(saved.c.type.isnot(None), or_(saved.c.type != 'function_call_output', pairs.c.call_index.is_(None)))
)
trace = union_all(messages, outputs).subquery()
query = select(trace)
if view == 'tools':
query = query.where(trace.c.type.in_(['function_call', 'function_call_output', 'open_webui:code_interpreter']))
elif view == 'errors':
query = query.where(trace.c.status == 'failed')
rows = (
(
await db.execute(
query.order_by(trace.c.created_at, trace.c.message_id, trace.c.index).offset(skip).limit(limit + 1)
)
)
.mappings()
.all()
)
session = {**dict(session), 'title': safe_snippet(session['title']) or ''}
result = {'session': session, **page_result(rows, limit)}
if include_chart:
result['chart'] = await trace_chart(db, trace, session['responses'])
normalize_durations(result['items'])
return result
async def trace_chart(db, trace, responses):
# Keep the full session's order and page offsets, but return at most 128 bars.
ordered = select(
trace,
(func.row_number().over(order_by=[trace.c.created_at, trace.c.message_id, trace.c.index]) - 1).label('offset'),
).cte('ordered_trace')
input_tokens, output_tokens = _token_columns(db.get_bind().dialect.name)
usage = (
select(
ordered.c.offset,
ordered.c.model_id,
ordered.c.status,
input_tokens.label('input_tokens'),
output_tokens.label('output_tokens'),
func.coalesce(ChatMessage.usage['incomplete'].as_boolean(), False).label('incomplete'),
func.row_number().over(order_by=ordered.c.offset).label('ordinal'),
)
.join(ChatMessage, ChatMessage.id == ordered.c.message_id)
.where(ordered.c.index == -1, ChatMessage.role == 'assistant')
.subquery()
)
group_size = max(1, math.ceil(responses / 128))
position = usage.c.ordinal - 1
bucket = cast((position - position % group_size) / group_size, Integer)
recorded = usage.c.input_tokens.isnot(None) | usage.c.output_tokens.isnot(None)
complete = usage.c.input_tokens.isnot(None) & usage.c.output_tokens.isnot(None) & usage.c.incomplete.is_(False)
bars = (
(
await db.execute(
select(
func.min(usage.c.ordinal).label('first'),
func.max(usage.c.ordinal).label('last'),
func.min(usage.c.offset).label('offset'),
func.sum(usage.c.input_tokens).label('input_tokens'),
func.sum(usage.c.output_tokens).label('output_tokens'),
func.sum(case((recorded, 1), else_=0)).label('recorded'),
func.sum(case((complete, 1), else_=0)).label('complete'),
func.sum(case((usage.c.status == 'failed', 1), else_=0)).label('errors'),
case(
(
func.count(usage.c.model_id) == func.count(),
case((func.count(distinct(usage.c.model_id)) == 1, func.min(usage.c.model_id))),
)
).label('model_id'),
)
.group_by(bucket)
.order_by(bucket)
)
)
.mappings()
.all()
)
# Validate numeric text before casting, including on PostgreSQL. Malformed saved values are unknown.
def number(column):
if db.get_bind().dialect.name == 'sqlite':
valid_json = case((func.json_valid(column), column), else_='null')
valid = func.json_type(valid_json).in_(['integer', 'real'])
else:
valid = column.op('~')(r'^[0-9]+([.][0-9]+)?([eE][+-]?[0-9]{1,2})?$')
value = cast(case((valid & (func.length(column) <= 100), column)), Float)
return case(((value >= 0) & (value < 1e100), value))
start, end, duration = [number(ordered.c[key]) for key in ('started_at', 'ended_at', 'duration_seconds')]
timed = start.isnot(None) & end.isnot(None) & (end >= start)
timing = (
(
await db.execute(
select(
func.min(case((timed, start))).label('start'),
func.max(case((timed, end))).label('end'),
func.max(func.coalesce(duration, case((timed, end - start)))).label('max_duration'),
func.sum(case((timed, 1), else_=0)).label('timed_operations'),
func.sum(case((duration.isnot(None) | timed, 1), else_=0)).label('duration_operations'),
)
)
)
.mappings()
.one()
)
return {'usage': [dict(bar) for bar in bars], **dict(timing)}

View file

@ -1,31 +1,31 @@
from contextlib import nullcontext
import os
import time
import uuid
from collections import Counter
from contextlib import nullcontext
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import select, delete, func, cast, Integer, distinct
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.response import merge_usage, normalize_usage
from open_webui.utils.response import normalize_usage
from open_webui.utils.session_records import normalize_tool_output, recorded_timestamp
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
ForeignKey,
Float,
ForeignKey,
Index,
Integer,
Text,
cast,
case,
cast,
delete,
distinct,
func,
literal,
select,
@ -255,7 +255,7 @@ class ChatMessageTable:
if 'content' in data:
message.content = data.get('content')
if 'output' in data:
message.output = data.get('output')
message.output = normalize_tool_output(data.get('output'))
if 'model_id' in data or 'model' in data:
message.model_id = data.get('model_id') or data.get('model')
if 'files' in data:
@ -277,8 +277,8 @@ class ChatMessageTable:
usage = get_usage(data)
if usage:
existing_usage = normalize_usage(message.usage)
message.usage = existing_usage if usage == existing_usage else merge_usage(existing_usage, usage)
# The caller saves a cumulative message snapshot; accumulation belongs to the model-call loop.
message.usage = usage
message.updated_at = now
@staticmethod
@ -290,7 +290,7 @@ class ChatMessageTable:
role=data.get('role', 'user'),
parent_id=data.get('parent_id') or data.get('parentId'),
content=data.get('content'),
output=data.get('output'),
output=normalize_tool_output(data.get('output')),
model_id=data.get('model_id') or data.get('model'),
files=data.get('files'),
sources=data.get('sources'),
@ -301,7 +301,7 @@ class ChatMessageTable:
error=data.get('error'),
usage=get_usage(data),
context_summary=data.get('context_summary') or data.get('contextSummary'),
created_at=data.get('timestamp') or now,
created_at=(recorded_timestamp(data.get('timestamp')) or 0) if 'timestamp' in data else now,
updated_at=now,
)
@ -336,15 +336,21 @@ class ChatMessageTable:
self, chat_id: str, user_id: str, messages: dict[str, dict], db: AsyncSession | None = None
) -> None:
"""Backfill missing rows without overwriting newer message data."""
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
if not messages:
return
async with get_async_db_context(db) as db:
insert = sqlite_insert if db.bind.dialect.name == 'sqlite' else pg_insert
rows = [
self._build_message(f'{chat_id}-{mid}', chat_id, data.get('user_id') or user_id, data, int(time.time()))
self._build_message(
f'{chat_id}-{mid}',
chat_id,
data.get('user_id') or user_id,
{**data, 'timestamp': data.get('timestamp')},
int(time.time()),
)
for mid, data in messages.items()
]
await db.execute(
@ -805,7 +811,7 @@ class ChatMessageTable:
day_stmt = day_stmt.filter(ChatMessage.created_at >= start_date)
if end_date:
day_stmt = day_stmt.filter(ChatMessage.created_at <= end_date)
day_result = await db.execute(day_stmt)
day_result = await db.execute(day_stmt.where(ChatMessage.created_at > 0))
active_days = {_date_key(row.created_at, tz) for row in day_result.all()}
input_total = int(token_result.input_tokens or 0)
@ -831,7 +837,7 @@ class ChatMessageTable:
result = await db.execute(
select(func.min(ChatMessage.created_at)).filter(
ChatMessage.user_id == user_id,
ChatMessage.created_at.isnot(None),
ChatMessage.created_at > 0,
)
)
value = result.scalar()
@ -865,7 +871,7 @@ class ChatMessageTable:
ChatMessage.created_at <= end_date,
)
result = await db.execute(stmt)
result = await db.execute(stmt.where(ChatMessage.created_at > 0))
daily: dict[str, dict] = {}
for row in result.all():
date = _date_key(row.created_at, tz)
@ -1082,7 +1088,7 @@ class ChatMessageTable:
if day is not None:
stmt = stmt.group_by(day, ChatMessage.model_id)
result = await db.execute(stmt)
result = await db.execute(stmt.where(ChatMessage.created_at > 0))
counts: dict[str, dict[str, int]] = {}
for timestamp, model_id, value in result.all():
date_str = timestamp if day is not None else bucket_start(timestamp).strftime(date_format)

View file

@ -58,6 +58,51 @@ def chat_search_terms(text: str) -> list[str]:
return list(dict.fromkeys(re.findall(r'[a-z0-9]+', text.lower())))
def chat_message_list(chat: dict) -> list:
if not isinstance(chat, dict):
return []
history = chat.get('history')
messages = history.get('messages') if isinstance(history, dict) else None
if not isinstance(messages, (dict, list)) or not messages:
messages = chat.get('messages')
if isinstance(messages, dict):
return list(messages.values())
return messages if isinstance(messages, list) else []
def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) -> str | None:
search_text = chat_search_content_query(search_text)
if not search_text:
return None
messages = chat_message_list(chat)
needles = list(dict.fromkeys([search_text, *chat_search_terms(search_text)]))
for needle in needles:
for message in messages:
if not isinstance(message, dict):
continue
content = message.get('content')
if not isinstance(content, str):
continue
index = content.lower().find(needle)
if index == -1:
continue
start = max(index - max_length // 2, 0)
end = min(start + max_length, len(content))
if index + len(needle) > end:
end = min(index + len(needle), len(content))
start = max(end - max_length, 0)
snippet = ' '.join(content[start:end].split())
return f'{"..." if start else ""}{snippet}{"..." if end < len(content) else ""}'
return None
def chat_search_message_content_match_sql(dialect_name: str, key: str) -> str:
if dialect_name == 'sqlite':
return f"""
@ -65,12 +110,14 @@ def chat_search_message_content_match_sql(dialect_name: str, key: str) -> str:
EXISTS (
SELECT 1
FROM json_each(Chat.chat, '$.history.messages') AS history_message
WHERE LOWER(history_message.value->>'content') LIKE '%' || :{key} || '%'
WHERE LOWER(CASE WHEN history_message.type = 'object'
THEN history_message.value->>'content' END) LIKE '%' || :{key} || '%'
)
OR EXISTS (
SELECT 1
FROM json_each(Chat.chat, '$.messages') AS legacy_message
WHERE LOWER(legacy_message.value->>'content') LIKE '%' || :{key} || '%'
WHERE LOWER(CASE WHEN legacy_message.type = 'object'
THEN legacy_message.value->>'content' END) LIKE '%' || :{key} || '%'
)
)
"""
@ -88,13 +135,15 @@ def chat_search_message_content_match_sql(dialect_name: str, key: str) -> str:
)
OR EXISTS (
SELECT 1
FROM json_each(Chat.chat#>'{{history,messages}}') AS history_message
FROM json_each(CASE WHEN json_typeof(Chat.chat#>'{{history,messages}}') = 'object'
THEN Chat.chat#>'{{history,messages}}' ELSE '{{}}'::json END) AS history_message
WHERE json_typeof(history_message.value->'content') = 'string'
AND LOWER(history_message.value->>'content') LIKE '%' || :{key} || '%'
)
OR EXISTS (
SELECT 1
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
FROM json_array_elements(CASE WHEN json_typeof(Chat.chat->'messages') = 'array'
THEN Chat.chat->'messages' ELSE '[]'::json END) AS legacy_message
WHERE json_typeof(legacy_message->'content') = 'string'
AND LOWER(legacy_message->>'content') LIKE '%' || :{key} || '%'
)
@ -791,7 +840,7 @@ class ChatTable:
user_id=message.get('user_id')
or (message['user'].get('id') if isinstance(message.get('user'), dict) else None)
or user_id,
data=message,
data={**message, 'timestamp': message.get('timestamp')},
)
except Exception as e:
log.warning(
@ -1087,18 +1136,22 @@ class ChatTable:
if message_id in messages:
# Voice updates must not replace approval-resume metadata, and approval
# pauses must retain the generated speech already attached to this turn.
# pauses must retain speech and response timing already attached to this turn.
existing_meta = messages[message_id].get('meta')
existing_meta = existing_meta if isinstance(existing_meta, dict) else {}
incoming_meta = message.get('meta')
if isinstance(incoming_meta, dict):
if set(incoming_meta) <= {'voice', 'task_id'}:
if set(incoming_meta) <= {'voice', 'task_id', 'response_started_at', 'response_ended_at'}:
message = {**message, 'meta': {**existing_meta, **incoming_meta}}
else:
message = {
**message,
'meta': {
**{key: existing_meta[key] for key in ('voice', 'task_id') if key in existing_meta},
**{
key: existing_meta[key]
for key in ('voice', 'task_id', 'response_started_at', 'response_ended_at')
if key in existing_meta
},
**incoming_meta,
},
}
@ -1820,6 +1873,11 @@ class ChatTable:
chat_ids = chat_ids.filter(ChatMessage.created_at >= filter.get('start_date'))
if filter.get('end_date'):
chat_ids = chat_ids.filter(ChatMessage.created_at <= filter.get('end_date'))
if filter.get('group_id'):
from open_webui.models.groups import group_user_memberships
group_users = select(group_user_memberships([filter['group_id']], True).c.user_id).scalar_subquery()
chat_ids = chat_ids.filter(ChatMessage.user_id.in_(group_users))
chat_ids = chat_ids.subquery()
@ -2162,9 +2220,9 @@ class ChatTable:
skip: int = 0,
limit: int = 60,
db: AsyncSession | None = None,
) -> list[ChatModel]:
) -> list[ChatTitleIdResponse]:
"""
Filters chats based on a search query using Python, allowing pagination using skip and limit.
Search chats without loading their bodies, with pagination applied in SQL.
"""
search_text = sanitize_text_for_db(search_text).lower().strip()
@ -2210,7 +2268,9 @@ class ChatTable:
search_terms = [term for term in chat_search_terms(phrase_query) if term != phrase_query]
async with get_async_db_context(db) as session:
stmt = select(Chat).filter(Chat.user_id == user_id)
stmt = select(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at, Chat.archived
).filter(Chat.user_id == user_id)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if is_archived is not None:
@ -2338,12 +2398,21 @@ class ChatTable:
# Perform pagination at the SQL level
stmt = stmt.offset(skip).limit(limit)
result = await session.execute(stmt)
all_chats = result.scalars().all()
all_chats = result.mappings().all()
log.info('The number of chats: %s', len(all_chats))
# Validate and return chats
return [ChatModel.model_validate(chat) for chat in all_chats]
return [ChatTitleIdResponse.model_validate(chat) for chat in all_chats]
async def get_chat_search_snippets(
self, ids: list[str], user_id: str, search_text: str, db: AsyncSession | None = None
) -> dict[str, str | None]:
if not ids or not chat_search_content_query(search_text):
return {}
async with get_async_db_context(db) as session:
result = await session.execute(select(Chat.id, Chat.chat).where(Chat.id.in_(ids), Chat.user_id == user_id))
return {id: chat_search_snippet(body, search_text) for id, body in result.all()}
async def get_chats_by_folder_id_and_user_id(
self,
@ -2537,10 +2606,9 @@ class ChatTable:
async with get_async_db_context(db) as session:
chat = await session.get(Chat, id)
chat.folder_id = folder_id
chat.updated_at = int(time.time())
if chat.user_id == user_id:
chat.last_read_at = int(time.time())
chat.pinned = False
chat.pinned = False
if folder_id is not None:
# Folder listings only show unarchived chats, so moving an archived
# chat into a folder would otherwise have no visible effect: the chat

View file

@ -27,6 +27,7 @@ DICT_CONFIG_KEY_ALIASES = {
'ollama.api_configs': ('OLLAMA_API_CONFIGS',),
'rag.mineru_params': ('MINERU_PARAMS',),
'rag.docling_params': ('DOCLING_PARAMS',),
'rag.mistral_ocr_params': ('MISTRAL_OCR_PARAMS',),
'web.search.linkup_search_params': ('LINKUP_SEARCH_PARAMS',),
'image_generation.automatic1111.api_params': ('AUTOMATIC1111_PARAMS',),
'image_generation.openai.params': ('IMAGES_OPENAI_API_PARAMS',),

View file

@ -54,6 +54,7 @@ class FolderMetadataResponse(BaseModel):
class FolderNameIdResponse(BaseModel):
id: str
user_id: str
name: str
meta: Optional[FolderMetadataResponse] = None
parent_id: Optional[str] = None

View file

@ -300,7 +300,7 @@ class KnowledgeTable:
elif source == 'local':
stmt = stmt.filter(
or_(
Knowledge.meta.is_(None),
Knowledge.meta['source'].as_string().is_(None),
Knowledge.meta['source'].as_string() != 'external',
)
)
@ -671,31 +671,6 @@ class KnowledgeTable:
except Exception:
return []
async def get_file_metadatas_by_id(
self, knowledge_id: str, db: Optional[AsyncSession] = None
) -> list[FileMetadataResponse]:
"""Column-only listing: File.data holds each file's full extracted
text, which metadata views must never load."""
try:
async with get_async_db_context(db) as db:
result = await db.execute(
select(File.id, File.hash, File.meta, File.created_at, File.updated_at)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
return [
FileMetadataResponse(
id=row.id,
hash=row.hash,
meta=row.meta,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in result.all()
]
except Exception:
return []
async def add_file_to_knowledge_by_id(
self,
knowledge_id: str,
@ -911,12 +886,38 @@ class KnowledgeTable:
"""Get all files in a KB with their directory_id from KnowledgeFile."""
try:
async with get_async_db_context(db) as db:
# Leave out File.data: it holds each file's full extracted text, which callers load per file.
result = await db.execute(
select(File, KnowledgeFile.directory_id)
select(
File.id,
File.user_id,
File.hash,
File.filename,
File.path,
File.meta,
File.created_at,
File.updated_at,
KnowledgeFile.directory_id,
)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
return [(FileModel.model_validate(file), dir_id) for file, dir_id in result.all()]
return [
(
FileModel(
id=row.id,
user_id=row.user_id,
hash=row.hash,
filename=row.filename,
path=row.path,
meta=row.meta,
created_at=row.created_at,
updated_at=row.updated_at,
),
row.directory_id,
)
for row in result.all()
]
except Exception:
return []

View file

@ -1,17 +1,389 @@
import json
import re
import time
import uuid
from functools import lru_cache
from typing import Optional
from copy import deepcopy
from html import escape
from typing import Literal, Optional
import markdown
from bs4 import BeautifulSoup
from open_webui.internal.db import Base, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.models.users import User, UserModel, UserResponse
from open_webui.utils.json_codec import JSONCodec
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import JSON, BigInteger, Boolean, Column, ForeignKey, Text, cast, delete, func, or_, select, update
from sqlalchemy import JSON, BigInteger, Column, ForeignKey, Text, cast, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
class NotePageConflict(ValueError):
pass
class NotePageOperation(BaseModel):
action: Literal['create', 'rename', 'duplicate', 'reorder', 'move', 'delete']
page_id: str | None = None
new_id: str | None = None
parent_id: str | None = None
before_id: str | None = None
title: str | None = Field(default=None, max_length=200)
order: list[str] | None = None
revision: int = Field(ge=0)
def order_pages(pages: list[dict]) -> list[dict]:
"""Keep sibling order while storing each subtree together, parent first."""
ids = {page['id'] for page in pages}
if len(ids) != len(pages):
raise ValueError('Page IDs must be unique')
children: dict[str | None, list[dict]] = {}
for page in pages:
if page['id'] == 'main':
page['parent_id'] = None
else:
page.setdefault('parent_id', None)
parent = page['parent_id']
if parent is not None and parent not in ids:
raise ValueError('Parent page no longer exists')
children.setdefault(parent, []).append(page)
ordered = []
roots = children.get(None, [])
roots.sort(key=lambda page: page['id'] != 'main')
stack = list(reversed(roots))
while stack:
page = stack.pop()
ordered.append(page)
stack.extend(reversed(children.get(page['id'], [])))
if len(ordered) != len(pages):
raise ValueError('A page cannot be nested inside itself or its subpages')
return ordered
def subtree_ids(pages: list[dict], page_id: str) -> set[str]:
# Tabs are in preorder, so all ancestors precede their descendants.
result = {page_id}
for page in pages:
if page.get('parent_id') in result:
result.add(page['id'])
return result
def page_paths(pages: list[dict]) -> dict[str, str]:
paths = {}
for page in pages:
parent_path = paths.get(page.get('parent_id'))
paths[page['id']] = f'{parent_path} / {page["title"]}' if parent_path else page['title']
return paths
def get_content_files(content: dict | None) -> list[dict]:
"""Attachment blocks are authoritative; HTML/Markdown support external note writers."""
if not isinstance(content, dict):
return []
document = content.get('json')
files = {}
if isinstance(document, dict):
stack = [document]
while stack:
node = stack.pop()
if not isinstance(node, dict):
continue
if node.get('type') == 'fileAttachment':
attrs = node.get('attrs') or {}
file_id = attrs.get('id')
if isinstance(file_id, str) and re.fullmatch(r'[\w-]+', file_id):
files[file_id] = {
'type': 'file',
'id': file_id,
'name': attrs.get('name') or file_id,
'size': attrs.get('size', 0),
}
stack.extend(reversed(node.get('content') or []))
else:
html = content.get('html') or markdown.markdown(content.get('md') or '')
for link in BeautifulSoup(html, 'html.parser').find_all('a', href=True):
match = re.fullmatch(r'/api/v1/files/([\w-]+)/content', link['href'])
if match:
file_id = match[1]
files[file_id] = {'type': 'file', 'id': file_id, 'name': link.get_text() or file_id, 'size': 0}
return list(files.values())
def migrate_page_files(page: dict, files: list[dict]) -> None:
"""Consume legacy attachment lists once, preserving existing content and inline images."""
content = page['content']
existing = {file['id'] for file in get_content_files(content)}
changed = False
for file in files:
if not isinstance(file, dict) or not file.get('id'):
continue
if file.get('type') == 'image':
reference = f'data://{file["id"]}'
url = file.get('url') or ''
if not url:
continue
# These assets also back images in older versions of the page.
for snapshot in [content, *(page.get('versions') or [])]:
if not isinstance(snapshot, dict):
continue
encoded = json.dumps(snapshot)
if reference in encoded:
snapshot.update(json.loads(encoded.replace(reference, json.dumps(url)[1:-1])))
changed = True
elif file['id'] not in existing:
attrs = {'id': file['id'], 'name': file.get('name') or file['id'], 'size': file.get('size') or 0}
href = f'/api/v1/files/{file["id"]}/content'
block = f'<div data-file-id="{escape(file["id"], quote=True)}"><a href="{escape(href, quote=True)}">{escape(attrs["name"])}</a></div>'
content['html'] = (content.get('html') or markdown.markdown(content.get('md') or '')) + block
content['md'] = (
(content.get('md') or '') + '\n\n' + f'[{attrs["name"].replace("[", "").replace("]", "")}]({href})'
)
if isinstance(content.get('json'), dict):
content['json'].setdefault('content', []).append({'type': 'fileAttachment', 'attrs': attrs})
existing.add(file['id'])
changed = True
if changed:
# Old collaboration documents must not overwrite the migrated content.
page['generation'] = page.get('generation', 0) + 1
def get_note_files(data: dict | None) -> list[dict]:
return [file for page in normalize_pages(data)['pages'] for file in get_content_files(page['content'])]
def normalize_pages(data: dict | None, note_title: str | None = None) -> dict:
data = deepcopy(data or {})
if not data.get('pages'):
data['pages'] = [
{
'id': 'main',
'title': note_title or 'Untitled',
'content': data.get('content') or {'json': None, 'html': '', 'md': ''},
'versions': data.get('versions') or [],
'revision': 0,
'generation': 0,
}
]
if not any(page['id'] == 'main' for page in data['pages']):
data['pages'].insert(
0,
{
'id': 'main',
'title': note_title or 'Untitled',
'content': {'json': None, 'html': '', 'md': ''},
'versions': [],
'revision': 0,
'generation': 0,
},
)
if note_title is not None:
for page in data['pages']:
if page['id'] == 'main' and page.get('inherit_note_title', True):
page['title'] = note_title or 'Untitled'
data['pages'] = order_pages(data['pages'])
legacy_files = data.pop('files', None) or []
for page in data['pages']:
files = page.pop('files', None)
if files is None:
# Old whole-note files belong to main; subpages only retain referenced images.
references = json.dumps([page.get('content'), page.get('versions')])
files = (
legacy_files
if page['id'] == 'main'
else [
file
for file in legacy_files
if isinstance(file, dict)
and file.get('type') == 'image'
and f'data://{file.get("id")}' in references
]
)
if files:
migrate_page_files(page, files)
data.setdefault('pages_revision', 0)
data.pop('versions', None)
# A derived projection keeps existing SQL search and whole-note retrieval cheap.
pages = data['pages']
paths = page_paths(pages)
data['content'] = (
deepcopy(pages[0]['content'])
if len(pages) == 1
else {
'json': None,
'md': '\n\n'.join(f'## {paths[p["id"]]}\n\n{p["content"].get("md", "")}' for p in pages),
'html': '',
}
)
data['search_text'] = '\n\n'.join(f'{paths[p["id"]]}\n{p["content"].get("md", "")}' for p in pages)
return data
def get_page(data: dict, page_id: str | None = None) -> dict:
pages = normalize_pages(data)['pages']
if page_id is None:
if len(pages) != 1:
raise NotePageConflict('Select a page before editing this note')
return pages[0]
page = next((page for page in pages if page['id'] == page_id), None)
if page is None:
raise NotePageConflict('Page no longer exists')
return page
def document_page(data: dict, document_id: str) -> dict:
parts = document_id.split(':')
if len(parts) == 2:
page = get_page(data)
if page.get('generation', 0) != 0:
raise NotePageConflict('Reload this page before editing')
return page
if len(parts) != 5 or parts[2] != 'page':
raise ValueError('Invalid note document ID')
page = get_page(data, parts[3])
if str(page.get('generation', 0)) != parts[4]:
raise NotePageConflict('Page changed; reload before editing')
return page
def apply_page_operation(data: dict, operation: NotePageOperation) -> dict:
data = normalize_pages(data)
pages = data['pages']
if operation.action in ('create', 'duplicate'):
if not operation.new_id:
raise ValueError('A new page ID is required')
uuid.UUID(operation.new_id)
if any(p['id'] == operation.new_id for p in pages):
return data
if operation.revision != data['pages_revision']:
raise NotePageConflict('Pages changed. Refresh and try again')
if operation.action == 'create':
title = (operation.title or '').strip()
if not title:
raise ValueError('Page title is required')
pages.append(
{
'id': operation.new_id,
'parent_id': operation.parent_id,
'title': title,
'content': {'json': None, 'html': '', 'md': ''},
'versions': [],
'revision': 0,
'generation': 0,
}
)
elif operation.action == 'reorder':
order = operation.order or []
if len(order) != len(pages) or set(order) != {p['id'] for p in pages}:
raise ValueError('Order must contain each page exactly once')
if order[0] != 'main':
raise ValueError('The default page must stay first')
lookup = {p['id']: p for p in pages}
data['pages'] = [lookup[id] for id in order]
else:
page = next((p for p in pages if p['id'] == operation.page_id), None)
if page is None:
raise NotePageConflict('Page no longer exists')
if page['id'] == 'main' and operation.action in ('move', 'delete'):
raise ValueError('The default page cannot be moved or deleted')
if operation.action == 'rename':
title = (operation.title or '').strip()
if not title:
raise ValueError('Page title is required')
page['title'] = title
if page['id'] == 'main':
page['inherit_note_title'] = False
elif operation.action == 'move':
parent_id = operation.parent_id
if operation.before_id == 'main':
raise ValueError('The default page must stay first')
branch_ids = subtree_ids(pages, page['id'])
if parent_id in branch_ids:
raise ValueError('A page cannot be nested inside itself or its subpages')
if parent_id is not None and not any(p['id'] == parent_id for p in pages):
raise ValueError('Parent page no longer exists')
branch = [p for p in pages if p['id'] in branch_ids]
remaining = [p for p in pages if p['id'] not in branch_ids]
position = len(remaining)
if operation.before_id is not None:
anchor = next((p for p in remaining if p['id'] == operation.before_id), None)
if anchor is None or anchor['parent_id'] != parent_id:
raise ValueError('Move target must be a sibling in the destination')
position = remaining.index(anchor)
page['parent_id'] = parent_id
data['pages'] = remaining[:position] + branch + remaining[position:]
elif operation.action == 'delete':
if len(pages) == 1:
raise ValueError('A note must have at least one page')
# Deleting one tab never silently deletes its children's content.
for child in pages:
if child['parent_id'] == page['id']:
child['parent_id'] = page['parent_id']
pages.remove(page)
elif operation.action == 'duplicate':
branch_ids = subtree_ids(pages, page['id'])
branch = [p for p in pages if p['id'] in branch_ids]
ids = {p['id']: str(uuid.uuid5(uuid.UUID(operation.new_id), p['id'])) for p in branch}
ids[page['id']] = operation.new_id
if set(ids.values()) & {p['id'] for p in pages}:
raise ValueError('A duplicated page ID already exists')
copies = deepcopy(branch)
for copy in copies:
copy.update(
id=ids[copy['id']],
parent_id=ids.get(copy['parent_id'], copy['parent_id']),
versions=[],
revision=0,
generation=0,
)
copies[0]['title'] = page['title'][:193] + ' (Copy)'
position = pages.index(branch[-1]) + 1
pages[position:position] = copies
data['pages_revision'] += 1
return normalize_pages(data)
def update_page_content(
data: dict, page_id: str | None, changes: dict, generation: int | None = None, revision: int | None = None
) -> dict:
data = normalize_pages(data)
target = get_page(data, page_id)
page = next(p for p in data['pages'] if p['id'] == target['id'])
if generation is not None and generation != page.get('generation', 0):
raise NotePageConflict('Page changed; reload before editing')
if revision is not None and revision != page.get('revision', 0):
raise NotePageConflict('Page content changed. Read it again before editing')
if 'content' in changes:
content = changes['content']
if not isinstance(content, dict) or any(
key in content and not isinstance(content[key], str) for key in ('md', 'html')
):
raise ValueError('Page content must contain text markdown and HTML')
page['content'] = {'json': None, 'html': '', 'md': '', **deepcopy(content)}
page['revision'] = page.get('revision', 0) + 1
# Socket snapshots retain their generation. External replacements invalidate old documents.
if generation is None:
page['generation'] = page.get('generation', 0) + 1
if 'versions' in changes:
page['versions'] = deepcopy(changes['versions'])
if 'files' in changes:
raise ValueError('Attach files through page content')
return normalize_pages(data)
def matching_page(data: dict, query: str = '') -> dict:
pages = normalize_pages(data)['pages']
paths = page_paths(pages)
terms = query.lower().split()
for require_all in (True, False):
for page in pages:
text = f'{paths[page["id"]]} {page["content"].get("md", "")}'.lower()
if terms and (all(t in text for t in terms) if require_all else any(t in text for t in terms)):
return page
return pages[0]
####################
# Note DB Schema
####################
@ -102,6 +474,8 @@ class NoteForm(BaseModel):
@field_validator('data', mode='before')
@classmethod
def sanitize_data(cls, data):
if isinstance(data, dict) and 'pages' in data:
raise ValueError('Create pages using page operations')
return sanitize_note_data(data)
@ -109,6 +483,10 @@ class NoteUpdateForm(BaseModel):
title: Optional[str] = None
data: Optional[dict] = None
meta: Optional[dict] = None
page_id: Optional[str] = None
page_revision: Optional[int] = None
page_generation: Optional[int] = None
page_operation: Optional[NotePageOperation] = None
@field_validator('data', mode='before')
@classmethod
@ -147,7 +525,7 @@ class NoteTable:
) -> NoteModel:
# We exclude access_grants to inject them
note_model = NoteModel.model_validate(note)
note_model.data = note_model.data or {}
note_model.data = normalize_pages(note_model.data, note_model.title)
note_model.access_grants = (
access_grants if access_grants is not None else await self._get_access_grants(note_model.id, db=db)
)
@ -178,6 +556,7 @@ class NoteTable:
}
)
note.data = normalize_pages(note.data, note.title)
new_note = Note(**note.model_dump(exclude={'access_grants', 'is_pinned'}))
db.add(new_note)
@ -221,7 +600,13 @@ class NoteTable:
or_(
func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{word}%'),
func.replace(
func.replace(Note.data['content']['md'].as_string(), '-', ''),
func.replace(
func.coalesce(
Note.data['search_text'].as_string(), Note.data['content']['md'].as_string()
),
'-',
'',
),
' ',
'',
).ilike(f'%{word}%'),
@ -331,7 +716,7 @@ class NoteTable:
async def get_note_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[NoteModel]:
async with get_async_db_context(db) as db:
result = await db.execute(select(Note).filter(Note.id == id))
result = await db.execute(select(Note).filter(Note.id == id).execution_options(populate_existing=True))
note = result.scalars().first()
return await self._to_note_model(note, db=db) if note else None
@ -349,39 +734,58 @@ class NoteTable:
note_id
for note_id, data in result.all()
if isinstance(data, dict)
and isinstance(data.get('files'), list)
and any(
isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file_id
for item in data['files']
for item in get_note_files(data)
)
]
async def update_note_by_id(
self, id: str, form_data: NoteUpdateForm, db: Optional[AsyncSession] = None
) -> Optional[NoteModel]:
changes = form_data.model_dump(exclude_unset=True)
incoming = changes.get('data') or {}
if 'pages' in incoming or 'pages_revision' in incoming:
raise ValueError('Use page operations instead of replacing all pages')
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
form_data = form_data.model_dump(exclude_unset=True)
note.data = sanitize_note_data(note.data) or {}
if 'title' in form_data:
note.title = form_data['title']
if 'data' in form_data:
note.data = {**(note.data or {}), **(form_data['data'] or {})}
if 'meta' in form_data:
note.meta = {**(note.meta or {}), **(form_data['meta'] or {})}
if not db.is_modified(note):
return await self._to_note_model(note, db=db)
note.updated_at = int(time.time_ns())
await db.commit()
return await self._to_note_model(note, db=db) if note else None
# CAS protects the entire JSON from writers on other pages and other workers.
for _ in range(8):
result = await db.execute(select(Note).where(Note.id == id).execution_options(populate_existing=True))
note = result.scalars().first()
if not note:
return None
previous_updated_at = note.updated_at
data = normalize_pages(sanitize_note_data(note.data), note.title)
if form_data.page_operation:
data = apply_page_operation(data, form_data.page_operation)
if any(key in incoming for key in ('content', 'versions', 'files')):
data = update_page_content(
data,
form_data.page_id or ('main' if not {'content', 'versions'} & incoming.keys() else None),
incoming,
form_data.page_generation,
form_data.page_revision,
)
data = normalize_pages(
{**data, **{k: v for k, v in incoming.items() if k not in ('content', 'versions', 'files')}},
changes.get('title', note.title),
)
values = {'data': data, 'updated_at': max(time.time_ns(), (previous_updated_at or 0) + 1)}
if 'title' in changes:
values['title'] = changes['title']
if 'meta' in changes:
values['meta'] = {**(note.meta or {}), **(changes['meta'] or {})}
result = await db.execute(
update(Note)
.where(Note.id == id, Note.updated_at == previous_updated_at)
.values(**values)
.execution_options(synchronize_session=False)
)
if result.rowcount:
await db.commit()
return await self.get_note_by_id(id, db=db)
await db.rollback()
raise NotePageConflict('Note changed while saving. Please try again')
async def toggle_note_pinned_by_id(
self, id: str, user_id: str, db: Optional[AsyncSession] = None

View file

@ -4,7 +4,6 @@ import re
import time
from typing import Any, Optional
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.models.config import Config
from open_webui.models.knowledge import KnowledgeModel
@ -105,7 +104,7 @@ async def _retrieve_qdrant(connection, auth_config, knowledge, query, count, emb
source_config = _source_config(knowledge)
vector_field = source_config.get('vector_field') or None
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
vector = await embedding_function(query, prefix=await Config.get('rag.embedding_query_prefix'))
def _search():
client = QdrantClient(
@ -154,7 +153,7 @@ async def _retrieve_milvus(connection, auth_config, knowledge, query, count, emb
content_field = source_config.get('content_field') or 'data.text'
metadata_field = source_config.get('metadata_field') or 'metadata'
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
vector = await embedding_function(query, prefix=await Config.get('rag.embedding_query_prefix'))
def _search():
client_kwargs = {
@ -232,7 +231,7 @@ async def _retrieve_pgvector(connection, auth_config, knowledge, query, count, e
metadata_field = source_config.get('metadata_field') or 'vmetadata'
document_id_field = source_config.get('document_id_field') or 'id'
vector = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
vector = await embedding_function(query, prefix=await Config.get('rag.embedding_query_prefix'))
def _search():
from psycopg import sql

View file

@ -704,6 +704,7 @@ class Loader:
api_key=self.config['rag.mistral_ocr_api_key'],
file_path=file_path,
use_base64=self.config['rag.mistral_ocr_use_base64'],
params=self.config['rag.mistral_ocr_params'],
user=self.user,
)
elif (

View file

@ -34,6 +34,7 @@ class MistralLoader:
max_retries: int = 3,
enable_debug_logging: bool = False,
use_base64: bool = False,
params: Optional[Dict[str, Any]] = None,
user: Optional[Any] = None,
):
"""
@ -46,6 +47,7 @@ class MistralLoader:
max_retries: Maximum number of retry attempts.
enable_debug_logging: Enable detailed debug logs.
use_base64: Send the document as a data URL instead of uploading it first.
params: Extra fields merged into the OCR request, overriding the defaults (e.g. model).
user: The requesting user, forwarded to Mistral via user-info headers
when ENABLE_FORWARD_USER_INFO_HEADERS is enabled.
"""
@ -60,6 +62,7 @@ class MistralLoader:
self.max_retries = max_retries
self.debug = enable_debug_logging
self.use_base64 = use_base64
self.params = params or {}
self.user = user
# PERFORMANCE OPTIMIZATION: Differentiated timeouts for different operations
@ -247,6 +250,7 @@ class MistralLoader:
'document_url': signed_url,
},
'include_image_base64': False,
**self.params,
}
def ocr_request():

View file

@ -19,9 +19,7 @@ from langchain_classic.retrievers import (
)
from langchain_core.documents import Document
from open_webui.config import (
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_PREFIX_FIELD_NAME,
RAG_EMBEDDING_QUERY_PREFIX,
VECTOR_DB,
)
from open_webui.constants import ERROR_MESSAGES
@ -213,6 +211,7 @@ async def get_content_from_url(request, url: str, *, config: dict | None = None)
'rag.mineru_params',
'rag.mistral_ocr_api_base_url',
'rag.mistral_ocr_api_key',
'rag.mistral_ocr_params',
'rag.mistral_ocr_use_base64',
'rag.paddleocr_vl_base_url',
'rag.paddleocr_vl_token',
@ -321,7 +320,7 @@ class VectorSearchRetriever(BaseRetriever):
*,
run_manager: CallbackManagerForRetrieverRun,
) -> list[Document]:
embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
embedding = await self.embedding_function(query, await Config.get('rag.embedding_query_prefix'))
result = await ASYNC_VECTOR_DB_CLIENT.search(
collection_name=self.collection_name,
vectors=[embedding],
@ -433,7 +432,7 @@ async def query_doc_with_native_hybrid_search(
query_vectors = []
if hybrid_bm25_weight < 1:
query_vectors = [await embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)]
query_vectors = [await embedding_function(query, await Config.get('rag.embedding_query_prefix'))]
result = await ASYNC_VECTOR_DB_CLIENT.hybrid_search(
collection_name=collection_name,
@ -696,6 +695,7 @@ async def query_collection(
) -> dict:
config = await Config.get_many(
'rag.enable_hybrid_search',
'rag.embedding_query_prefix',
'rag.top_k_reranker',
'rag.relevance_threshold',
'rag.hybrid_bm25_weight',
@ -749,7 +749,7 @@ async def query_collection(
return {'distances': [[]], 'documents': [[]], 'metadatas': [[]]}
# Generate all query embeddings (in one call)
query_embeddings = await embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
query_embeddings = await embedding_function(queries, prefix=config['rag.embedding_query_prefix'])
log.debug('query_collection: processing %s queries across %s collections', len(queries), len(collection_names))
task_results = await asyncio.gather(
@ -1462,8 +1462,20 @@ async def get_sources_from_items(
):
# User has access to the note
query_result = {
'documents': [[note.data.get('content', {}).get('md', '')]],
'metadatas': [[{'file_id': note.id, 'name': note.title}]],
'documents': [[page['content'].get('md', '') for page in note.data['pages']]],
'metadatas': [
[
{
'file_id': note.id,
'note_id': note.id,
'page_id': page['id'],
'parent_id': page.get('parent_id'),
'name': f'{note.title} / {page["title"]}',
'source': f'/notes/{note.id}?page={page["id"]}',
}
for page in note.data['pages']
]
],
}
elif item.get('type') == 'chat':
@ -1851,9 +1863,11 @@ class RerankCompressor(BaseDocumentCompressor):
if reranking:
scores = await asyncio.to_thread(self.reranking_function, query, documents)
else:
query_embedding = await self.embedding_function(query, RAG_EMBEDDING_QUERY_PREFIX)
query_embedding = await self.embedding_function(query, await Config.get('rag.embedding_query_prefix'))
doc_texts = [doc.page_content for doc in documents]
document_embedding = await self.embedding_function(doc_texts, RAG_EMBEDDING_CONTENT_PREFIX)
document_embedding = await self.embedding_function(
doc_texts, await Config.get('rag.embedding_content_prefix')
)
scores = cosine_similarity(query_embedding, document_embedding)
if scores is not None:

View file

@ -592,11 +592,13 @@ class S3VectorClient(VectorDBBase):
if ids:
# Delete by specific vector IDs/keys
log.info("Deleting %s vectors by IDs from collection '%s'", len(ids), collection_name)
self.client.delete_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
keys=ids,
)
# S3 Vectors accepts at most 500 keys per delete request.
for start in range(0, len(ids), 500):
self.client.delete_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
keys=ids[start : start + 500],
)
log.info("Deleted %s vectors from index '%s'", len(ids), collection_name)
elif filter:
@ -623,11 +625,7 @@ class S3VectorClient(VectorDBBase):
log.info('Found %s vectors matching filter, deleting them', len(matching_ids))
# Delete the matching vectors by ID
self.client.delete_vectors(
vectorBucketName=self.bucket_name,
indexName=collection_name,
keys=matching_ids,
)
self.delete(collection_name, ids=matching_ids)
log.info("Deleted %s vectors from index '%s' using filter", len(matching_ids), collection_name)
else:
log.warning('No vectors found matching the filter criteria')

View file

@ -117,6 +117,18 @@ def _assert_addresses_allowed(addresses: Sequence[str]) -> None:
raise ValueError(ERROR_MESSAGES.INVALID_URL)
def is_identity_encoding(content_encodings: Sequence[str]) -> bool:
"""True when no content coding was applied, so the body costs what the sender transmitted."""
return all(content_encoding.lower() in ('', 'identity') for content_encoding in content_encodings)
def assert_identity_encoding(content_encodings: Sequence[str]) -> None:
"""Reject a compressed body, whose decoded size the sender would choose for us."""
if not is_identity_encoding(content_encodings):
log.warning('Blocked compressed response body: %s', ', '.join(content_encodings))
raise ValueError(ERROR_MESSAGES.COMPRESSED_RESPONSE)
def validate_url(url: Union[str, Sequence[str]]):
if isinstance(url, str):
if isinstance(validators.url(url, simple_host=ENABLE_LOCAL_WEB_FETCH), validators.ValidationError):
@ -239,7 +251,9 @@ class _SSRFSafeAdapter(HTTPAdapter):
def send(self, request, *args, **kwargs):
# Per request, not per connection: the connection layer sees the proxy.
_assert_host_allowed(urllib.parse.urlparse(request.url).hostname)
return super().send(request, *args, **kwargs)
response = super().send(request, *args, **kwargs)
assert_identity_encoding(response.raw.headers.getlist('Content-Encoding'))
return response
class _SSRFSafeConnector(aiohttp.TCPConnector):
@ -304,7 +318,8 @@ _UNWRAP_EXTRA_MAINS = (
def _forwardable_request_headers(headers: Dict[str, str]) -> Dict[str, str]:
return {name: value for name, value in headers.items() if name.lower() not in _DROPPED_REQUEST_HEADERS}
forwardable = {name: value for name, value in headers.items() if name.lower() not in _DROPPED_REQUEST_HEADERS}
return forwardable | {'Accept-Encoding': 'identity'}
def _fulfillable_response_headers(header_pairs: Iterable[Tuple[str, str]]) -> Dict[str, str]:
@ -913,6 +928,7 @@ class SafePlaywrightURLLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
ssl=AIOHTTP_CLIENT_SESSION_SSL if self.verify_ssl else False,
timeout=aiohttp.ClientTimeout(total=self._request_timeout()),
)
assert_identity_encoding(response.headers.getall('Content-Encoding', ()))
# aiohttp only returns the connection to the pool once the body is buffered
return response, await response.read()
@ -1034,6 +1050,7 @@ class SafePlaywrightURLLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
WEB_LOADER_HEADERS = {
'User-Agent': USER_AGENT or 'DefaultLangchainUserAgent',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Encoding': 'identity',
'Accept-Language': 'en-US,en;q=0.5',
'Referer': 'https://www.google.com/',
'DNT': '1',
@ -1107,6 +1124,7 @@ class SafeWebBaseLoader(BaseLoader):
async with session.get(url, **options) as response:
if self.raise_for_status:
response.raise_for_status()
assert_identity_encoding(response.headers.getall('Content-Encoding', ()))
return await response.text()
except aiohttp.ClientConnectionError as e:
if i == retries - 1:

View file

@ -48,6 +48,7 @@ class UserAnalyticsEntry(BaseModel):
class UserAnalyticsResponse(BaseModel):
users: list[UserAnalyticsEntry]
total: int
####################
@ -77,7 +78,8 @@ async def get_user_analytics(
start_date: Optional[int] = Query(None, description='Start timestamp (epoch)'),
end_date: Optional[int] = Query(None, description='End timestamp (epoch)'),
group_id: Optional[str] = Query(None, description='Filter by user group ID'),
limit: int = Query(50, description='Max users to return'),
skip: int = Query(0, ge=0, description='Number of users to skip'),
limit: int = Query(50, ge=1, description='Max users to return'),
metric: Literal['messages', 'tokens'] = Query('messages'),
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
@ -91,8 +93,9 @@ async def get_user_analytics(
key=lambda uid: (
-usage[uid]['total_tokens'] if metric == 'tokens' else -usage[uid]['message_count'],
-usage[uid]['message_count'],
uid or '',
),
)[:limit]
)[skip : skip + limit]
user_info = {u.id: u for u in await Users.get_users_by_user_ids(top_user_ids, db=db)}
users = []
@ -111,7 +114,7 @@ async def get_user_analytics(
)
)
return UserAnalyticsResponse(users=users)
return UserAnalyticsResponse(users=users, total=len(usage))
@router.get('/messages', response_model=list[ChatMessageModel])
@ -310,6 +313,7 @@ async def get_model_chats(
limit: int = Query(50, le=100),
order_by: str = Query('updated_at'),
direction: str = Query('desc'),
group_id: Optional[str] = Query(None, description='Filter by user group ID'),
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
@ -319,6 +323,8 @@ async def get_model_chats(
filter['start_date'] = start_date
if end_date:
filter['end_date'] = end_date
if group_id:
filter['group_id'] = group_id
if order_by in MODEL_CHAT_ORDER_FIELDS:
filter['order_by'] = order_by
if direction in {'asc', 'desc'}:
@ -438,3 +444,139 @@ async def get_model_overview(
tags = [TagEntry(tag=tag, count=count) for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]]
return ModelOverviewResponse(history=history, tags=tags)
####################
# Session Explorer
####################
def require_session_access(user=Depends(get_admin_user)):
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS
if not ENABLE_ADMIN_CHAT_ACCESS:
raise HTTPException(403, detail='Admin chat access is disabled')
return user
def validate_session_range(start_date, end_date):
if start_date is not None and end_date is not None and start_date > end_date:
raise HTTPException(400, detail='Start date must be before end date')
@router.get('/sessions')
async def get_sessions(
start_date: int | None = Query(None, ge=0),
end_date: int | None = Query(None, ge=0),
group_id: str | None = None,
query: str = Query('', max_length=200),
model_id: str | None = None,
hide_subagents: bool = False,
order_by: Literal['title', 'user', 'recent', 'tokens', 'responses', 'errors', 'duration'] = 'recent',
direction: Literal['asc', 'desc'] = 'desc',
include_summary: bool = False,
token_min: int | None = Query(None, ge=0),
token_max: int | None = Query(None, ge=1),
skip: int = Query(0, ge=0),
limit: int = Query(25, ge=1, le=50),
user=Depends(require_session_access),
db: AsyncSession = Depends(get_async_session),
):
from open_webui.models.analytics_sessions import list_sessions
validate_session_range(start_date, end_date)
if token_min is not None and token_max is not None and token_min >= token_max:
raise HTTPException(400, detail='Minimum tokens must be below maximum tokens')
return await list_sessions(
db,
start_date=start_date,
end_date=end_date,
group_id=group_id,
query=query,
model_id=model_id,
hide_subagents=hide_subagents,
order_by=order_by,
direction=direction,
include_summary=include_summary,
token_min=token_min,
token_max=token_max,
skip=skip,
limit=limit,
)
@router.get('/sessions/{chat_id}')
async def get_session_messages(
chat_id: str,
start_date: int | None = Query(None, ge=0),
end_date: int | None = Query(None, ge=0),
group_id: str | None = None,
skip: int = Query(0, ge=0),
limit: int = Query(25, ge=1, le=50),
user=Depends(require_session_access),
db: AsyncSession = Depends(get_async_session),
):
from open_webui.models.analytics_sessions import session_messages
validate_session_range(start_date, end_date)
result = await session_messages(
db, chat_id, start_date=start_date, end_date=end_date, group_id=group_id, skip=skip, limit=limit
)
if result is None:
raise HTTPException(404, detail='Session not found in this range')
return result
@router.get('/sessions/{chat_id}/messages/{message_id}/events')
async def get_session_events(
chat_id: str,
message_id: str,
start_date: int | None = Query(None, ge=0),
end_date: int | None = Query(None, ge=0),
group_id: str | None = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=50),
user=Depends(require_session_access),
db: AsyncSession = Depends(get_async_session),
):
from open_webui.models.analytics_sessions import message_events
validate_session_range(start_date, end_date)
result = await message_events(
db, chat_id, message_id, start_date=start_date, end_date=end_date, group_id=group_id, skip=skip, limit=limit
)
if result is None:
raise HTTPException(404, detail='Message not found in this session')
return result
@router.get('/sessions/{chat_id}/trace')
async def get_session_trace(
chat_id: str,
start_date: int | None = Query(None, ge=0),
end_date: int | None = Query(None, ge=0),
group_id: str | None = None,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=50),
view: Literal['all', 'tools', 'errors'] = 'all',
include_chart: bool = False,
user=Depends(require_session_access),
db: AsyncSession = Depends(get_async_session),
):
from open_webui.models.analytics_sessions import session_trace
validate_session_range(start_date, end_date)
result = await session_trace(
db,
chat_id,
start_date=start_date,
end_date=end_date,
group_id=group_id,
skip=skip,
limit=limit,
view=view,
include_chart=include_chart,
)
if result is None:
raise HTTPException(404, detail='Session not found in this range')
return result

View file

@ -30,8 +30,6 @@ from open_webui.models.chats import (
ChatTitleIdResponse,
ChatUsageStatsListResponse,
MessageStats,
chat_search_content_query,
chat_search_terms,
)
from open_webui.models.config import Config
from open_webui.models.folders import Folders
@ -227,47 +225,6 @@ class CompactChatForm(BaseModel):
model: str | None = None
def chat_search_content_text(text: str) -> str:
return chat_search_content_query(text)
def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) -> str | None:
if not search_text:
return None
history = chat.get('history', {})
messages = history.get('messages') if isinstance(history, dict) else None
if not messages:
messages = chat.get('messages', []) or []
if isinstance(messages, dict):
messages = messages.values()
needles = list(dict.fromkeys([search_text, *chat_search_terms(search_text)]))
for needle in needles:
for message in messages:
if not isinstance(message, dict):
continue
content = message.get('content')
if not isinstance(content, str):
continue
index = content.lower().find(needle)
if index == -1:
continue
start = max(index - max_length // 2, 0)
end = min(start + max_length, len(content))
if index + len(needle) > end:
end = min(index + len(needle), len(content))
start = max(end - max_length, 0)
snippet = ' '.join(content[start:end].split())
return f'{"..." if start else ""}{snippet}{"..." if end < len(content) else ""}'
return None
async def get_chat_config_values() -> dict:
values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
@ -954,21 +911,7 @@ async def search_user_chats(
limit = 60
skip = (page - 1) * limit
search_text = chat_search_content_text(text)
chat_list = []
for chat in await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db):
# Explicit fields: model_dump() would deep-copy the entire chat blob per row
chat_list.append(
ChatTitleIdResponse(
id=chat.id,
title=chat.title,
updated_at=chat.updated_at,
created_at=chat.created_at,
last_read_at=chat.last_read_at,
snippet=chat_search_snippet(chat.chat, search_text),
archived=chat.archived,
)
)
chat_list = await Chats.get_chats_by_user_id_and_search_text(user.id, text, skip=skip, limit=limit, db=db)
# Delete tag if no chat is found
words = text.strip().split(' ')
@ -982,6 +925,17 @@ async def search_user_chats(
return await add_active_state_to_chat_list(request, chat_list)
@router.get('/{id}/search/snippet', response_model=str | None)
async def get_chat_search_snippet(
id: str,
text: str,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
snippets = await Chats.get_chat_search_snippets([id], user.id, text, db=db)
return snippets.get(id)
############################
# GetChatsByFolderId
############################

View file

@ -29,7 +29,7 @@ from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
from open_webui.retrieval.web.utils import assert_identity_encoding, get_ssrf_safe_session, validate_url
from open_webui.routers.files import get_file_content_by_id, upload_file_handler
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_admin_user, get_verified_user
@ -940,9 +940,13 @@ async def image_edits(
# that passed validate_url cannot reach an internal address.
async with get_ssrf_safe_session() as session:
async with session.get(
data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
data,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
headers={'Accept-Encoding': 'identity'},
) as r:
r.raise_for_status()
assert_identity_encoding(r.headers.getall('Content-Encoding', ()))
image_data = base64.b64encode(await r.read()).decode('utf-8')
return f'data:{r.headers["content-type"]};base64,{image_data}'

View file

@ -14,7 +14,6 @@ from fastapi.responses import StreamingResponse
from open_webui.config import (
BYPASS_ADMIN_ACCESS_CONTROL,
ENABLE_KNOWLEDGE_FILE_RETENTION,
RAG_EMBEDDING_CONTENT_PREFIX,
)
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
@ -98,7 +97,9 @@ async def embed_knowledge_base_metadata(
"""Generate and store embedding for knowledge base."""
try:
content = f'{name}\n\n{description}' if description else name
embedding = await request.app.state.EMBEDDING_FUNCTION(content, prefix=RAG_EMBEDDING_CONTENT_PREFIX)
embedding = await request.app.state.EMBEDDING_FUNCTION(
content, prefix=await Config.get('rag.embedding_content_prefix')
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=KNOWLEDGE_BASES_COLLECTION,
items=[
@ -1179,7 +1180,7 @@ async def update_knowledge_by_id(
)
response = KnowledgeFilesResponse(
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(knowledge.id),
files=[],
)
await publish_event(
request,
@ -1248,7 +1249,7 @@ async def update_knowledge_access_by_id(
response = KnowledgeFilesResponse(
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(id, db=db),
files=[],
)
await publish_event(
request,
@ -1488,7 +1489,7 @@ async def add_file_to_knowledge_by_id(
if knowledge:
response = KnowledgeFilesResponse(
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
files=await Files.get_file_metadatas_by_ids([form_data.file_id], db=db),
)
await publish_event(
request,
@ -1572,7 +1573,7 @@ async def update_file_from_knowledge_by_id(
if knowledge:
response = KnowledgeFilesResponse(
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
files=await Files.get_file_metadatas_by_ids([form_data.file_id], db=db),
)
await publish_event(
request,
@ -1661,7 +1662,7 @@ async def remove_file_from_knowledge_by_id(
if knowledge:
response = KnowledgeFilesResponse(
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
files=[],
)
await publish_event(
request,
@ -2104,7 +2105,7 @@ async def add_files_to_knowledge_batch(
if not new_entries:
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
files=[],
)
# Narrow the file list to only new files for processing
@ -2140,7 +2141,7 @@ async def add_files_to_knowledge_batch(
error_details = [f'{err.file_id}: {err.error}' for err in result.errors]
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
files=await Files.get_file_metadatas_by_ids(successful_file_ids, db=db),
warnings={
'message': 'Some files failed to process',
'errors': error_details,
@ -2149,7 +2150,7 @@ async def add_files_to_knowledge_batch(
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=await Knowledges.get_file_metadatas_by_id(knowledge.id, db=db),
files=await Files.get_file_metadatas_by_ids(successful_file_ids, db=db),
)

View file

@ -4,7 +4,6 @@ import logging
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, status
from open_webui.config import RAG_EMBEDDING_CONTENT_PREFIX, RAG_EMBEDDING_QUERY_PREFIX
from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
@ -144,7 +143,7 @@ async def reindex_memory_vectors_for_user(
# One list call so batching and the concurrency limit apply.
vectors = await request.app.state.EMBEDDING_FUNCTION(
[memory_vector_text(memory.content, memory.path) for memory in memories],
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
prefix=await Config.get('rag.embedding_content_prefix'),
user=user,
)
@ -200,7 +199,9 @@ async def add_memory(
)
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
memory_vector_text(memory.content, memory.path),
prefix=await Config.get('rag.embedding_content_prefix'),
user=user,
)
await upsert_memory_vectors_or_reindex(
@ -256,6 +257,7 @@ async def update_memories(
delete_ids = []
response = []
content_prefix = await Config.get('rag.embedding_content_prefix')
for result in results:
memory = result.get('memory')
if isinstance(memory, MemoryModel):
@ -263,7 +265,7 @@ async def update_memories(
if result.get('status') in {'created', 'updated'}:
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
prefix=content_prefix,
user=user,
)
upsert_items.append(
@ -340,7 +342,9 @@ async def query_memory(
if not memories:
raise HTTPException(status_code=404, detail='No memories found for user')
vector = await request.app.state.EMBEDDING_FUNCTION(form_data.content, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user)
vector = await request.app.state.EMBEDDING_FUNCTION(
form_data.content, prefix=await Config.get('rag.embedding_query_prefix'), user=user
)
results = await ASYNC_VECTOR_DB_CLIENT.search(
collection_name=f'user-memory-{user.id}',
@ -570,7 +574,9 @@ async def update_memory_by_id(
if form_data.content is not None or form_data.path is not None:
vector = await request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
memory_vector_text(memory.content, memory.path),
prefix=await Config.get('rag.embedding_content_prefix'),
user=user,
)
await upsert_memory_vectors_or_reindex(

View file

@ -19,12 +19,18 @@ from open_webui.models.notes import (
NoteForm,
NoteListResponse,
NoteModel,
NotePageConflict,
NotePageOperation,
Notes,
NoteUpdateForm,
NoteUserResponse,
get_content_files,
get_note_files,
matching_page,
normalize_pages,
)
from open_webui.models.users import UserModel, UserResponse, Users
from open_webui.socket.main import leave_room_for_users, sio
from open_webui.socket.main import close_changed_note_documents, leave_room_for_users, sio
from open_webui.utils.access_control import (
filter_allowed_access_grants,
has_permission,
@ -56,11 +62,17 @@ async def check_notes_access(user: UserModel, db: AsyncSession):
)
def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optional[dict]:
def _truncate_note_data(data: Optional[dict], max_length: int = 1000, query: str = '') -> Optional[dict]:
if not data:
return data
md = (data.get('content') or {}).get('md') or ''
return {'content': {'md': md[:max_length]}}
page = matching_page(data, query)
md = page['content'].get('md') or ''
return {
'content': {'md': md[:max_length]},
'page_count': len(data.get('pages') or [None]),
'page_id': page['id'],
'page_title': page['title'],
}
async def leave_note_rooms_for_revoked_users(
@ -76,7 +88,10 @@ async def leave_note_rooms_for_revoked_users(
users = await Users.get_users_by_user_ids(list(revoked_user_ids), db=db)
# Admins retain access to notes regardless of grants.
user_ids = [user.id for user in users if user.role != 'admin']
for room in [f'note:{note.id}', f'doc_note:{note.id}']:
page_rooms = [
f'doc_note:{note.id}:page:{p["id"]}:{p.get("generation", 0)}' for p in normalize_pages(note.data)['pages']
]
for room in [f'note:{note.id}', f'doc_note:{note.id}', *page_rooms]:
await leave_room_for_users(room, user_ids)
@ -210,7 +225,7 @@ async def search_notes(
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)
note.data = _truncate_note_data(note.data, query=query or '')
return result
@ -237,6 +252,9 @@ async def create_new_note(
db=db,
)
if not await has_access_to_note_files(None, get_note_files(form_data.data), user, db=db):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
note = await Notes.insert_new_note(user.id, form_data, db=db)
await publish_event(
@ -534,15 +552,17 @@ async def update_note_by_id(
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
if (
form_data.data
and 'files' in form_data.data
and not await has_access_to_note_files(note, form_data.data['files'], user, db=db)
if form_data.data and not await has_access_to_note_files(
note, get_content_files(form_data.data.get('content')), user, db=db
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
if form_data.page_generation is not None:
raise ValueError('Page generation is managed by collaboration')
previous = note
note = await Notes.update_note_by_id(id, form_data, db=db)
await close_changed_note_documents(previous, note)
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
note.is_pinned = note.id in pinned_note_ids
@ -552,6 +572,8 @@ async def update_note_by_id(
event_data['data'] = {
key: note.data.get(key) for key in form_data.data.keys() if note.data is not None and key in note.data
}
if form_data.page_operation or (form_data.data and {'content', 'files'} & form_data.data.keys()):
event_data['data'] = note.data
await sio.emit(
'events:note',
@ -567,11 +589,26 @@ async def update_note_by_id(
data={'title': note.title},
)
return note
except NotePageConflict as e:
raise HTTPException(status_code=409, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
log.exception(e)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
@router.post('/{id}/pages/update', response_model=NoteModel)
async def update_note_pages(
request: Request,
id: str,
form_data: NotePageOperation,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
return await update_note_by_id(request, id, NoteUpdateForm(page_operation=form_data), user, db)
############################
# UpdateNoteAccessById
############################
@ -704,8 +741,13 @@ async def delete_note_by_id(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
try:
previous = note
page_rooms = [
f'doc_note:{id}:page:{p["id"]}:{p.get("generation", 0)}' for p in normalize_pages(note.data)['pages']
]
note = await Notes.delete_note_by_id(id, db=db)
for room in [f'note:{id}', f'doc_note:{id}']:
await close_changed_note_documents(previous, None)
for room in [f'note:{id}', f'doc_note:{id}', *page_rooms]:
await sio.close_room(room)
await publish_event(

View file

@ -7,6 +7,7 @@ import mimetypes
import os
import re
import shutil
import time
import uuid
from datetime import datetime
from pathlib import Path
@ -37,10 +38,8 @@ from langchain_text_splitters import (
from open_webui.config import (
Config,
DEFAULT_LOCALE,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_MODEL_AUTO_UPDATE,
RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
RAG_EMBEDDING_QUERY_PREFIX,
RAG_RERANKING_MODEL_AUTO_UPDATE,
RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
UPLOAD_DIR,
@ -51,6 +50,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
DEVICE_TYPE,
DOCKER,
RAG_EMBEDDING_BUFFER_SIZE,
RAG_EMBEDDING_TIMEOUT,
SENTENCE_TRANSFORMERS_BACKEND,
SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
@ -95,7 +95,7 @@ from open_webui.retrieval.web.firecrawl import search_firecrawl
from open_webui.retrieval.web.google_pse import search_google_pse
from open_webui.retrieval.web.jina_search import search_jina
from open_webui.retrieval.web.kagi import search_kagi
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
from open_webui.retrieval.web.utils import assert_identity_encoding, get_ssrf_safe_session, validate_url
# Web search engines
from open_webui.retrieval.web.main import SearchResult
@ -328,6 +328,7 @@ RETRIEVAL_CONFIG_KEYS = {
'MICROSOFT_WEB_IQ_LANGUAGE': 'web.search.microsoft_web_iq_language',
'MISTRAL_OCR_API_BASE_URL': 'rag.mistral_ocr_api_base_url',
'MISTRAL_OCR_API_KEY': 'rag.mistral_ocr_api_key',
'MISTRAL_OCR_PARAMS': 'rag.mistral_ocr_params',
'MISTRAL_OCR_USE_BASE64': 'rag.mistral_ocr_use_base64',
'MOJEEK_SEARCH_API_KEY': 'web.search.mojeek_search_api_key',
'OLLAMA_CLOUD_WEB_SEARCH_API_KEY': 'web.search.ollama_cloud_api_key',
@ -347,6 +348,8 @@ RETRIEVAL_CONFIG_KEYS = {
'RAG_EMBEDDING_BATCH_SIZE': 'rag.embedding_batch_size',
'RAG_EMBEDDING_CONCURRENT_REQUESTS': 'rag.embedding_concurrent_requests',
'RAG_EMBEDDING_ENGINE': 'rag.embedding_engine',
'RAG_EMBEDDING_QUERY_PREFIX': 'rag.embedding_query_prefix',
'RAG_EMBEDDING_CONTENT_PREFIX': 'rag.embedding_content_prefix',
'RAG_EMBEDDING_MODEL': 'rag.embedding_model',
'RAG_TOKENIZER_MODEL': 'rag.tokenizer_model',
'RAG_EXTERNAL_RERANKER_API_KEY': 'rag.external_reranker_api_key',
@ -443,6 +446,8 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
return {
'status': True,
'RAG_EMBEDDING_ENGINE': config['rag.embedding_engine'],
'RAG_EMBEDDING_QUERY_PREFIX': config['rag.embedding_query_prefix'],
'RAG_EMBEDDING_CONTENT_PREFIX': config['rag.embedding_content_prefix'],
'RAG_EMBEDDING_MODEL': config['rag.embedding_model'],
'RAG_EMBEDDING_BATCH_SIZE': config['rag.embedding_batch_size'],
'ENABLE_ASYNC_EMBEDDING': config['rag.enable_async_embedding'],
@ -484,6 +489,8 @@ class EmbeddingModelUpdateForm(BaseModel):
ollama_config: OllamaConfigForm | None = None
azure_openai_config: AzureOpenAIConfigForm | None = None
RAG_EMBEDDING_ENGINE: str
RAG_EMBEDDING_QUERY_PREFIX: str | None = None
RAG_EMBEDDING_CONTENT_PREFIX: str | None = None
RAG_EMBEDDING_MODEL: str
RAG_EMBEDDING_BATCH_SIZE: int | None = 1
ENABLE_ASYNC_EMBEDDING: bool | None = True
@ -517,10 +524,14 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd
updates = {
'rag.embedding_engine': form_data.RAG_EMBEDDING_ENGINE,
'rag.embedding_model': form_data.RAG_EMBEDDING_MODEL.strip(),
'rag.embedding_batch_size': form_data.RAG_EMBEDDING_BATCH_SIZE,
'rag.embedding_batch_size': form_data.RAG_EMBEDDING_BATCH_SIZE or 1,
'rag.enable_async_embedding': form_data.ENABLE_ASYNC_EMBEDDING,
'rag.embedding_concurrent_requests': form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS,
}
if 'RAG_EMBEDDING_QUERY_PREFIX' in form_data.model_fields_set:
updates['rag.embedding_query_prefix'] = form_data.RAG_EMBEDDING_QUERY_PREFIX or None
if 'RAG_EMBEDDING_CONTENT_PREFIX' in form_data.model_fields_set:
updates['rag.embedding_content_prefix'] = form_data.RAG_EMBEDDING_CONTENT_PREFIX or None
if form_data.RAG_EMBEDDING_ENGINE == 'openai' and form_data.openai_config is not None:
updates['rag.openai.api_base_url'] = form_data.openai_config.url or ''
updates['rag.openai.api_key'] = form_data.openai_config.key or ''
@ -572,6 +583,8 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd
return {
'status': True,
'RAG_EMBEDDING_ENGINE': config['rag.embedding_engine'],
'RAG_EMBEDDING_QUERY_PREFIX': config['rag.embedding_query_prefix'],
'RAG_EMBEDDING_CONTENT_PREFIX': config['rag.embedding_content_prefix'],
'RAG_EMBEDDING_MODEL': config['rag.embedding_model'],
'RAG_EMBEDDING_BATCH_SIZE': config['rag.embedding_batch_size'],
'ENABLE_ASYNC_EMBEDDING': config['rag.enable_async_embedding'],
@ -644,6 +657,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
'MISTRAL_OCR_API_BASE_URL': config['rag.mistral_ocr_api_base_url'],
'MISTRAL_OCR_API_KEY': config['rag.mistral_ocr_api_key'],
'MISTRAL_OCR_USE_BASE64': config['rag.mistral_ocr_use_base64'],
'MISTRAL_OCR_PARAMS': config['rag.mistral_ocr_params'],
'PADDLEOCR_VL_BASE_URL': config['rag.paddleocr_vl_base_url'],
'PADDLEOCR_VL_TOKEN': config['rag.paddleocr_vl_token'],
# MinerU settings
@ -890,6 +904,7 @@ class ConfigForm(BaseModel):
MISTRAL_OCR_API_BASE_URL: str | None = None
MISTRAL_OCR_API_KEY: str | None = None
MISTRAL_OCR_USE_BASE64: bool | None = None
MISTRAL_OCR_PARAMS: dict | None = None
PADDLEOCR_VL_BASE_URL: str | None = None
PADDLEOCR_VL_TOKEN: str | None = None
@ -1076,6 +1091,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'MISTRAL_OCR_API_BASE_URL': config['rag.mistral_ocr_api_base_url'],
'MISTRAL_OCR_API_KEY': config['rag.mistral_ocr_api_key'],
'MISTRAL_OCR_USE_BASE64': config['rag.mistral_ocr_use_base64'],
'MISTRAL_OCR_PARAMS': config['rag.mistral_ocr_params'],
'PADDLEOCR_VL_BASE_URL': config['rag.paddleocr_vl_base_url'],
'PADDLEOCR_VL_TOKEN': config['rag.paddleocr_vl_token'],
# MinerU settings
@ -1519,38 +1535,68 @@ def save_docs_to_vector_db(
concurrent_requests=config['rag.embedding_concurrent_requests'],
)
batch_size = config['rag.embedding_batch_size']
buffer_size = RAG_EMBEDDING_BUFFER_SIZE
if isinstance(batch_size, int) and batch_size > 0:
# Round up to whole provider batches to preserve request boundaries.
buffer_size = batch_size * ((buffer_size + batch_size - 1) // batch_size)
# Run async embedding in sync context using the main event loop
# This allows the main loop to stay responsive to health checks during long operations
embedding_timeout = RAG_EMBEDDING_TIMEOUT
future = asyncio.run_coroutine_threadsafe(
embedding_function(
list(map(lambda x: x.replace('\n', ' '), texts)),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user,
),
request.app.state.main_loop,
)
embeddings = future.result(timeout=embedding_timeout)
log.info('embeddings generated %s for %s items', len(embeddings), len(texts))
items = [
{
'id': str(uuid.uuid4()),
'text': text,
'vector': embeddings[idx],
'metadata': metadatas[idx],
}
for idx, text in enumerate(texts)
]
log.info('adding to collection %s', collection_name)
get_vector_db_client().insert(
collection_name=collection_name,
items=items,
)
inserted_ids = []
try:
for start in range(0, len(texts), buffer_size):
buffer_texts = texts[start : start + buffer_size]
future = asyncio.run_coroutine_threadsafe(
embedding_function(
[text.replace('\n', ' ') for text in buffer_texts],
prefix=config['rag.embedding_content_prefix'],
user=user,
),
request.app.state.main_loop,
)
started_at = time.monotonic()
try:
embeddings = future.result(timeout=embedding_timeout)
except TimeoutError:
future.cancel()
raise
if embedding_timeout is not None:
embedding_timeout = max(embedding_timeout - (time.monotonic() - started_at), 0)
log.info('embeddings generated %s for %s items', len(embeddings), len(buffer_texts))
log.info('added %s items to collection %s', len(items), collection_name)
items = [
{
'id': str(uuid.uuid4()),
'text': text,
'vector': embeddings[idx],
'metadata': metadatas[start + idx],
}
for idx, text in enumerate(buffer_texts)
]
inserted_ids.extend(item['id'] for item in items)
get_vector_db_client().insert(
collection_name=collection_name,
items=items,
)
del items, embeddings, future
except Exception:
if inserted_ids:
try:
if add:
get_vector_db_client().delete(collection_name=collection_name, ids=inserted_ids)
else:
# This call created/replaced the collection; an empty one would make retries skip indexing.
get_vector_db_client().delete_collection(collection_name=collection_name)
except Exception:
log.exception('failed to remove partially added items from collection %s', collection_name)
raise
log.info('added %s items to collection %s', len(texts), collection_name)
return True
except Exception as e:
log.exception(e)
@ -1907,13 +1953,14 @@ async def _fetch_url(url: str, max_size_mb: int | str | None) -> dict:
except (TypeError, ValueError):
max_bytes = None
headers = {'User-Agent': USER_AGENT} if USER_AGENT else None
headers = {'Accept-Encoding': 'identity'} | ({'User-Agent': USER_AGENT} if USER_AGENT else {})
async with get_ssrf_safe_session() as session:
async with session.get(
url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
) as response:
response.raise_for_status()
assert_identity_encoding(response.headers.getall('Content-Encoding', ()))
content_type = response.headers.get('Content-Type', '')
content_disposition = response.headers.get('Content-Disposition', '')
@ -2790,7 +2837,7 @@ async def query_doc_handler(
)
else:
query_embedding = await request.app.state.EMBEDDING_FUNCTION(
form_data.query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user
form_data.query, prefix=config['rag.embedding_query_prefix'], user=user
)
# query_doc wraps a blocking get_vector_db_client().search call;
# offload so the request's event loop stays responsive.

View file

@ -7,12 +7,12 @@ from fastapi import APIRouter, Depends, Query
from open_webui.internal.db import get_async_session
from open_webui.models.access_grants import AccessGrant, AccessGrants
from open_webui.models.channels import ChannelMember, Channels
from open_webui.models.chats import Chat
from open_webui.models.chats import Chat, chat_message_list, chat_search_content_query
from open_webui.models.config import Config
from open_webui.models.folders import Folder
from open_webui.models.folders import Folder, Folders
from open_webui.models.groups import Groups
from open_webui.models.messages import Message
from open_webui.models.notes import Note, PinnedNote
from open_webui.models.notes import Note, PinnedNote, matching_page
from open_webui.models.shared_chats import SharedChat
from open_webui.models.users import User
from open_webui.utils.access_control import has_permission
@ -73,9 +73,7 @@ def excerpt(content: str, query: str, length: int = 200) -> str:
def chat_passage(data: dict, query: str) -> tuple[str, str | None]:
history = (data.get('history') or {}).get('messages') or {}
messages = list(history.values()) if history else data.get('messages') or []
messages = [m for m in messages if isinstance(m, dict) and isinstance(m.get('content'), str)]
messages = [m for m in chat_message_list(data) if isinstance(m, dict) and isinstance(m.get('content'), str)]
terms = terms_for(query)
match = next((m for m in messages if terms and all(t in m['content'].lower() for t in terms)), None)
if match is None:
@ -97,13 +95,19 @@ def message_match_sql(table: str, dialect: str, key: str):
return text(
'('
+ ' OR '.join(
f"EXISTS (SELECT 1 FROM {source} AS m WHERE json_type(m.value, '$.content') = 'text' "
f"AND lower(m.value ->> 'content') LIKE :{key} ESCAPE '\\')"
f"EXISTS (SELECT 1 FROM {source} AS m WHERE lower(CASE WHEN m.type = 'object' "
f"AND json_type(m.value, '$.content') = 'text' THEN m.value ->> 'content' END) "
f"LIKE :{key} ESCAPE '\\')"
for source in sources
)
+ ')'
)
sources = [f"json_each({table}.chat->'history'->'messages')", f"json_array_elements({table}.chat->'messages')"]
sources = [
f"json_each(CASE WHEN json_typeof({table}.chat->'history'->'messages') = 'object' "
f"THEN {table}.chat->'history'->'messages' ELSE '{{}}'::json END)",
f"json_array_elements(CASE WHEN json_typeof({table}.chat->'messages') = 'array' "
f"THEN {table}.chat->'messages' ELSE '[]'::json END)",
]
return text(
'('
+ ' OR '.join(
@ -215,7 +219,16 @@ async def search_workspace( # noqa: C901 — keep permission scoping and source
if scope and scope not in {s.id for s in scopes}:
return SearchResponse(items=[], has_more=False, scopes=scopes, kinds=list(enabled))
query = query.strip()
words = query.lower().split()
tag_ids = [word.removeprefix('tag:') for word in words if word.startswith('tag:') and word != 'tag:']
folder_names = [word.removeprefix('folder:') for word in words if word.startswith('folder:') and word != 'folder:']
flags = {
name: value == 'true'
for name, _, value in (word.partition(':') for word in words)
if name in ('pinned', 'archived', 'shared') and value in ('true', 'false')
}
chat_filtered = bool(tag_ids or folder_names or flags)
query = chat_search_content_query(query)
terms = terms_for(query)
results = []
dialect = db.bind.dialect.name
@ -273,14 +286,35 @@ async def search_workspace( # noqa: C901 — keep permission scoping and source
)
stmt = select(Chat).where(
live_access,
Chat.archived.is_(False),
Chat.archived.is_(flags.get('archived', False)),
func.coalesce(Chat.meta['internal'].as_boolean(), False).is_(False),
sharing_filter(Chat.user_id),
)
if scope:
stmt = stmt.where(Chat.folder_id.in_(scoped_folders))
if chat_filtered:
stmt = stmt.where(Chat.user_id == user.id)
if 'pinned' in flags:
stmt = stmt.where(Chat.pinned.is_(flags['pinned']))
if 'shared' in flags:
stmt = stmt.where(Chat.share_id.isnot(None) if flags['shared'] else Chat.share_id.is_(None))
if folder_names:
matched_folders = await Folders.search_folders_by_names(user.id, folder_names, db=db)
stmt = stmt.where(Chat.folder_id.in_([folder.id for folder in matched_folders]))
tags, tag = (
("json_each(chat.meta, '$.tags') AS tag", 'tag.value')
if dialect == 'sqlite'
else ("json_array_elements_text(chat.meta->'tags') AS tag", 'tag')
)
for index, tag_id in enumerate(tag_ids):
key = f'tag_{index}'
stmt = stmt.where(
text(f'NOT EXISTS (SELECT 1 FROM {tags})')
if tag_id == 'none'
else text(f'EXISTS (SELECT 1 FROM {tags} WHERE {tag} = :{key})').bindparams(**{key: tag_id})
)
sources['chat'] = (Chat, apply_match(stmt, Chat), rank, Chat.updated_at)
if not scope and sharing != 'mine':
if not scope and sharing != 'mine' and not chat_filtered:
stmt = select(SharedChat).where(
granted('shared_chat', SharedChat.chat_id), ~SharedChat.chat_id.in_(live_ids)
)
@ -293,7 +327,7 @@ async def search_workspace( # noqa: C901 — keep permission scoping and source
SharedChat.updated_at,
)
if 'note' in enabled and kind in ('all', 'note') and not scope:
if 'note' in enabled and kind in ('all', 'note') and not scope and not chat_filtered:
pinned = select(PinnedNote.id).where(PinnedNote.note_id == Note.id, PinnedNote.user_id == user.id).exists()
note_shared = (
select(AccessGrant.id)
@ -308,11 +342,13 @@ async def search_workspace( # noqa: C901 — keep permission scoping and source
db, select(Note, pinned, note_shared), Note, {'user_id': user.id, 'group_ids': groups}, 'note', 'read'
)
stmt = stmt.where(sharing_filter(Note.user_id))
stmt = apply_match(stmt, Note, Note.data['content']['md'].as_string())
stmt = apply_match(
stmt, Note, func.coalesce(Note.data['search_text'].as_string(), Note.data['content']['md'].as_string())
)
rank = title_rank(Note.title, query) if query else case((pinned, 0), else_=3)
sources['note'] = (Note, stmt, rank, cast(Note.updated_at.op('/')(literal(1_000_000_000)), BigInteger))
if 'message' in enabled and kind in ('all', 'message') and not scope.startswith('folder:'):
if 'message' in enabled and kind in ('all', 'message') and not scope.startswith('folder:') and not chat_filtered:
ids = [scope.removeprefix('channel:')] if scope else list(channel_map)
stmt = select(Message, User.name).outerjoin(User, User.id == Message.user_id).where(Message.channel_id.in_(ids))
stmt = stmt.where(sharing_filter(Message.user_id))
@ -391,14 +427,15 @@ async def search_workspace( # noqa: C901 — keep permission scoping and source
match.rank,
)
elif match.source == 'note':
content = ((item.data or {}).get('content') or {}).get('md') or ''
page = matching_page(item.data, query)
content = page['content'].get('md', '')
append_result(
'note:' + item.id,
'note',
item.title,
'Shared note' if item.user_id != user.id or row[2] else 'Personal note',
content if isinstance(content, str) else '',
'/notes/' + item.id + ('?' + urlencode({'search': query}) if query else ''),
'/notes/' + item.id + '?' + urlencode({'page': page['id'], 'search': query}),
match.updated_at,
item.user_id != user.id or row[2],
row[1],

View file

@ -39,6 +39,7 @@ from open_webui.utils.access_control import (
validate_access_grants,
)
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.misc import is_string_allowed
from open_webui.utils.plugin import (
get_tool_contents_cache,
get_tool_module_from_cache,
@ -128,6 +129,10 @@ async def get_tools(
server_id = f'server:{server.get("id")}'
server_connections[server_id] = connection
function_name_filter_list = (connection.get('config') or {}).get('function_name_filter_list', '')
if isinstance(function_name_filter_list, str):
function_name_filter_list = function_name_filter_list.split(',')
tools.append(
ToolUserResponse(
**{
@ -137,6 +142,11 @@ async def get_tools(
'meta': {
'description': server.get('openapi', {}).get('info', {}).get('description', ''),
},
'specs': [
spec
for spec in server.get('specs', [])
if not function_name_filter_list or is_string_allowed(spec['name'], function_name_filter_list)
],
'updated_at': int(time.time()),
'created_at': int(time.time()),
}

View file

@ -16,7 +16,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.events import EVENTS, publish_event
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
from open_webui.models.auths import Auths
from open_webui.models.auths import ApiKey, Auths
from open_webui.models.config import Config
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import Chats
@ -41,6 +41,7 @@ from open_webui.models.models import Models
from open_webui.models.tools import Tools
from open_webui.utils.access_control import fill_missing_permissions, get_permissions, has_permission
from open_webui.utils.auth import (
create_api_key,
get_admin_user,
get_password_hash,
get_verified_user,
@ -925,6 +926,70 @@ async def revoke_user_sessions(request: Request, user_id: str, session_user=Depe
return True
@router.post('/{user_id}/api_key/rotate', response_model=ApiKey)
async def rotate_user_api_key_by_id(
request: Request,
response: Response,
user_id: str,
session_user: UserModel = Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
target_user = await Users.get_user_by_id(user_id, db=db)
if target_user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.USER_NOT_FOUND)
first_user = await Users.get_first_user(db=db)
if first_user and first_user.id == user_id and session_user.id != user_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
if not await Config.get('auth.enable_api_keys') or (
target_user.role != 'admin'
and not await has_permission(target_user.id, 'features.api_keys', await Config.get('user.permissions'), db=db)
):
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_CREATION_NOT_ALLOWED)
api_key = create_api_key()
await Users.update_user_api_key_by_id(user_id, api_key, db=db)
await publish_event(
request,
EVENTS.AUTH_API_KEY_CREATED,
actor=session_user,
subject_id=user_id,
subject_type='user',
source='admin',
)
response.headers['Cache-Control'] = 'no-store'
return {'api_key': api_key}
@router.delete('/{user_id}/api_key', response_model=bool)
async def delete_user_api_key_by_id(
request: Request,
user_id: str,
session_user: UserModel = Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
target_user = await Users.get_user_by_id(user_id, db=db)
if target_user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.USER_NOT_FOUND)
first_user = await Users.get_first_user(db=db)
if first_user and first_user.id == user_id and session_user.id != user_id:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
# Revocation must remain available when API keys or the user's permission are disabled.
await Users.delete_user_api_key_by_id(user_id, db=db)
await publish_event(
request,
EVENTS.AUTH_API_KEY_DELETED,
actor=session_user,
subject_id=user_id,
subject_type='user',
source='admin',
)
return True
@router.post('/{user_id}/update', response_model=UserModel | None)
async def update_user_by_id(
request: Request,

View file

@ -40,7 +40,7 @@ from open_webui.models.channels import Channels
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.notes import Notes, NoteUpdateForm
from open_webui.models.notes import Notes, NoteUpdateForm, document_page, get_content_files, get_page, normalize_pages
from open_webui.models.users import UserNameResponse, Users
from open_webui.socket.encryption import Relay
from open_webui.socket.redis_room_channels import AsyncRedisRoomChannelManager
@ -274,6 +274,46 @@ async def publish_chat_presence(chat_id: str):
log.exception('Failed to publish presence for chat %s', chat_id)
async def publish_note_presence(note_id: str):
lock = (
REDIS.lock(
f'{REDIS_KEY_PREFIX}:note:{note_id}:presence:lock',
timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT,
blocking_timeout=WEBSOCKET_REDIS_LOCK_TIMEOUT,
)
if REDIS is not None
else SOCKET_EVENT_LOCKS.setdefault(('note', note_id), asyncio.Lock())
)
async with lock, asyncio.timeout(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2):
now = time.time()
people = {}
for batch in get_session_pool_batches():
for _, session in batch:
if (
not session
or note_id not in session.get('note_ids', [])
or now - session.get('last_seen_at', 0) > SESSION_POOL_TIMEOUT
):
continue
location = session.get('note_locations', {}).get(note_id, {})
previous = people.get(session['id'])
if previous is None or location.get('updated_at', 0) > previous.get('updated_at', 0):
people[session['id']] = {**location, 'id': session['id'], 'name': session.get('name', '')}
if REDIS is not None and not await lock.owned():
return
await sio.emit(
'note:presence',
{'note_id': note_id, 'users': sorted(people.values(), key=lambda person: person['id'])},
room=f'note:{note_id}',
)
async def remove_document_session(sid: str):
for document_id in await YDOC_MANAGER.remove_user_from_all_documents(sid):
async with YDOC_MANAGER.lock(document_id):
await sio.leave_room(sid, f'doc_{document_id}')
async def periodic_session_pool_cleanup():
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
@ -306,6 +346,12 @@ async def periodic_session_pool_cleanup():
if sid in expired
for chat_id in entry.get('chat_ids', [])
}
note_ids = {
note_id
for sid, entry in batch
if sid in expired
for note_id in entry.get('note_ids', [])
}
if WEBSOCKET_MANAGER == 'redis':
SESSION_POOL.delete_many(*expired)
else:
@ -315,8 +361,13 @@ async def periodic_session_pool_cleanup():
if sid in expired:
for chat_id in entry.get('chat_ids', []):
await sio.leave_room(sid, f'chat:{chat_id}')
for note_id in entry.get('note_ids', []):
await sio.leave_room(sid, f'note:{note_id}')
await remove_document_session(sid)
for chat_id in chat_ids:
await publish_chat_presence(chat_id)
for note_id in note_ids:
await publish_note_presence(note_id)
await asyncio.sleep(0) # don't hold the loop for the whole sweep
next_cleanup_at = time.monotonic() + SESSION_POOL_TIMEOUT
@ -500,7 +551,15 @@ async def leave_room_for_users(room: str, user_ids: list[str]):
"""Make all sessions of each user leave a room, including sessions on other workers."""
for sid in get_session_ids_by_user_ids(user_ids):
try:
await sio.leave_room(sid, room)
if room.startswith('doc_'):
document_id = room.removeprefix('doc_')
async with YDOC_MANAGER.lock(document_id):
await sio.leave_room(sid, room)
await YDOC_MANAGER.remove_user(document_id, sid)
elif room.startswith('note:'):
await leave_note(sid, {'note_id': room.removeprefix('note:')})
else:
await sio.leave_room(sid, room)
except Exception as e:
log.debug('Failed to make session %s leave room %s: %s', sid, room, e)
@ -603,7 +662,7 @@ async def user_join(sid, data):
'last_seen_at': int(time.time()),
}
SESSION_POOL[sid] = {**socket_user, 'chat_ids': (SESSION_POOL.get(sid) or {}).get('chat_ids', [])}
SESSION_POOL[sid] = {**(SESSION_POOL.get(sid) or {}), **socket_user}
await sio.save_session(sid, {'user': socket_user, 'token': auth['token']})
LOCAL_AUTHENTICATED_SIDS.add(sid)
await sio.enter_room(sid, f'user:{user.id}')
@ -666,8 +725,8 @@ async def heartbeat(sid, data):
user = await get_socket_session_user(sid)
if user:
SESSION_POOL[sid] = {
**(SESSION_POOL.get(sid) or {}),
**user,
'chat_ids': (SESSION_POOL.get(sid) or {}).get('chat_ids', []),
'last_seen_at': int(time.time()),
}
await Users.update_last_active_by_id(user['id'])
@ -695,7 +754,7 @@ async def join_channel(sid, data):
await sio.enter_room(sid, f'channel:{channel.id}')
@sio.on('join-note')
@sio.on('note:join')
async def join_note(sid, data):
auth = data['auth'] if 'auth' in data else None
if not auth or 'token' not in auth:
@ -734,6 +793,27 @@ async def join_note(sid, data):
log.debug('Joining note %s for user %s', note.id, user.id)
await sio.enter_room(sid, f'note:{note.id}')
session = SESSION_POOL.get(sid)
if session:
SESSION_POOL[sid] = {**session, 'note_ids': list(set(session.get('note_ids', [])) | {note.id})}
await publish_note_presence(note.id)
@sio.on('note:leave')
async def leave_note(sid, data):
note_id = data.get('note_id')
if isinstance(note_id, str):
await sio.leave_room(sid, f'note:{note_id}')
session = SESSION_POOL.get(sid)
if session:
locations = dict(session.get('note_locations', {}))
locations.pop(note_id, None)
SESSION_POOL[sid] = {
**session,
'note_ids': [id for id in session.get('note_ids', []) if id != note_id],
'note_locations': locations,
}
await publish_note_presence(note_id)
@sio.on('events:channel')
@ -886,6 +966,23 @@ def normalize_document_id(document_id: str) -> str:
return document_id
async def close_changed_note_documents(previous, current):
remaining = {p['id']: p.get('generation', 0) for p in normalize_pages(current.data)['pages']} if current else {}
for page in normalize_pages(previous.data)['pages']:
if remaining.get(page['id']) == page.get('generation', 0):
continue
ids = [f'note:{previous.id}:page:{page["id"]}:{page.get("generation", 0)}']
if len(previous.data['pages']) == 1:
ids.append(f'note:{previous.id}')
for document_id in ids:
async with YDOC_MANAGER.lock(document_id):
await stop_item_tasks(REDIS, document_id)
await sio.close_room(f'doc_{document_id}')
for sid in list(await YDOC_MANAGER.get_users(document_id)):
await YDOC_MANAGER.remove_user(document_id, sid)
await YDOC_MANAGER.clear_document(document_id)
def with_document_lock(handler):
@wraps(handler)
async def wrapped(sid, data):
@ -920,6 +1017,7 @@ async def ydoc_document_join(sid, data):
if not note:
log.error(f'Note {note_id} not found')
return
page = document_page(note.data, document_id)
if (
user.get('role') != 'admin'
@ -934,8 +1032,8 @@ async def ydoc_document_join(sid, data):
log.error(f'User {user.get("id")} does not have access to note {note_id}')
return
user_id = data.get('user_id', sid)
user_name = data.get('user_name', 'Anonymous')
user_id = user['id']
user_name = user.get('name', '')
user_color = data.get('user_color', '#000000')
if (
@ -950,6 +1048,17 @@ async def ydoc_document_join(sid, data):
# Join Socket.IO room
await sio.enter_room(sid, f'doc_{document_id}')
if document_id.startswith('note:'):
session = SESSION_POOL.get(sid)
if session:
SESSION_POOL[sid] = {
**session,
'note_locations': {
**session.get('note_locations', {}),
note_id: {'page_id': page['id'], 'document_id': document_id, 'updated_at': time.time()},
},
}
await publish_note_presence(note_id)
# Get the Yjs document state
ydoc = Y.Doc()
@ -964,7 +1073,7 @@ async def ydoc_document_join(sid, data):
{
'document_id': document_id,
'state': list(state_update), # Convert bytes to list for JSON
'content': note.data.get('content') if document_id.startswith('note:') and note.data else None,
'content': page['content'] if document_id.startswith('note:') else None,
},
room=sid,
)
@ -1012,7 +1121,31 @@ async def document_save_handler(document_id, data, user):
log.error(f'User {user.get("id")} does not have write access to note {note_id}')
return
return await Notes.update_note_by_id(note_id, NoteUpdateForm(data=data))
page = document_page(note.data, document_id)
if not isinstance(data.get('content'), dict):
return None
editor = await Users.get_user_by_id(user['id'])
if not editor or not await has_access_to_note_files(note, get_content_files(data['content']), editor):
raise ValueError('Cannot attach files you cannot access')
saved = await Notes.update_note_by_id(
note_id,
NoteUpdateForm(
data={'content': data['content']},
page_id=page['id'],
page_generation=page.get('generation', 0),
),
)
if saved:
await sio.emit(
'events:note',
{
'id': note_id,
'page': get_page(saved.data, page['id']),
'updated_at': saved.updated_at,
},
to=f'note:{note_id}',
)
return saved
@sio.on('ydoc:document:state')
@ -1084,6 +1217,7 @@ async def yjs_document_update(sid, data):
if not note:
log.error(f'Note {note_id} not found')
return
page = document_page(note.data, document_id)
if (
user.get('role') != 'admin'
@ -1099,9 +1233,11 @@ async def yjs_document_update(sid, data):
return
snapshot = data.get('data')
if isinstance(snapshot, dict) and 'files' in snapshot:
if isinstance(snapshot, dict) and 'content' in snapshot:
editor = await Users.get_user_by_id(user['id'])
if not editor or not await has_access_to_note_files(note, snapshot['files'], editor):
if not editor or not await has_access_to_note_files(
note, get_content_files(snapshot['content']), editor
):
await sio.emit('error', {'message': 'Cannot attach files you cannot access'}, room=sid)
return
@ -1143,6 +1279,11 @@ async def yjs_document_update(sid, data):
if data.get('initialize'):
return update if stored else None
if data.get('flush'):
await stop_item_tasks(REDIS, document_id)
saved = await document_save_handler(document_id, data.get('data', {}), user)
return {'ok': bool(saved)}
async def debounced_save():
await asyncio.sleep(0.5)
async with YDOC_MANAGER.lock(document_id):
@ -1167,6 +1308,7 @@ async def yjs_document_update(sid, data):
except Exception as e:
log.error(f'Error in yjs_document_update: {e}')
return {'error': str(e)}
@sio.on('ydoc:document:leave')
@ -1214,6 +1356,29 @@ async def yjs_awareness_update(sid, data):
if room not in sio.rooms(sid): # must have joined the document first
return
update = data['update']
if document_id.startswith('note:'):
note_id = document_id.split(':')[1]
session = SESSION_POOL.get(sid)
location = (session or {}).get('note_locations', {}).get(note_id)
cursor = data.get('cursor')
position = data.get('cursor_position')
if (
location
and location.get('document_id') == document_id
and isinstance(cursor, dict)
and isinstance(cursor.get('head'), dict)
and len(dumps_bytes(cursor)) <= 1024
and isinstance(position, int)
and 0 <= position <= 100_000_000
):
SESSION_POOL[sid] = {
**session,
'note_locations': {
**session.get('note_locations', {}),
note_id: {**location, 'cursor': cursor, 'cursor_position': position, 'updated_at': time.time()},
},
}
await publish_note_presence(note_id)
# Broadcast to the room; user_id is the authenticated identity, not client-supplied
await sio.emit(
@ -1233,7 +1398,11 @@ async def disconnect(sid, reason=None):
LOCAL_AUTHENTICATED_SIDS.discard(sid)
if sid in SESSION_POOL:
chat_ids = (SESSION_POOL.get(sid) or {}).get('chat_ids', [])
note_ids = (SESSION_POOL.get(sid) or {}).get('note_ids', [])
del SESSION_POOL[sid]
for note_id in note_ids:
await sio.leave_room(sid, f'note:{note_id}')
await publish_note_presence(note_id)
for chat_id in chat_ids:
await sio.leave_room(sid, f'chat:{chat_id}')
await publish_chat_presence(chat_id)
@ -1247,10 +1416,7 @@ async def disconnect(sid, reason=None):
else:
USAGE_POOL[model_id] = connections
await YDOC_MANAGER.remove_user_from_all_documents(sid)
else:
pass
# print(f"Unknown session ID {sid} disconnected")
await remove_document_session(sid)
async def redis_event_listener() -> None:

View file

@ -393,7 +393,7 @@ class YdocManager:
if not self._users[document_id]:
del self._users[document_id]
async def remove_user_from_all_documents(self, user_id: str):
async def remove_user_from_all_documents(self, user_id: str) -> list[str]:
if self._redis:
session_key = f'{self._redis_key_prefix}:session:{user_id}:documents'
document_ids = await self._redis.smembers(session_key)
@ -405,6 +405,7 @@ class YdocManager:
await self.remove_user(document_id, user_id)
if not await self.get_users(document_id) and not await has_active_tasks(self._redis, document_id):
await self.clear_document(document_id)
return list(document_ids)
async def clear_document(self, document_id: str):
if self._redis:

View file

@ -14,7 +14,6 @@ from urllib.parse import unquote
from fastapi import HTTPException, Request
from open_webui.config import RAG_EMBEDDING_QUERY_PREFIX
from open_webui.env import (
KNOWLEDGE_GREP_MAX_MATCHES,
VIEW_FILE_DEFAULT_MAX_CHARS,
@ -27,8 +26,8 @@ from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.memories import Memories
from open_webui.models.messages import Message, Messages
from open_webui.models.notes import Notes
from open_webui.models.users import UserModel
from open_webui.models.notes import NotePageConflict, Notes, get_content_files, get_page, matching_page
from open_webui.models.users import UserModel, Users
from open_webui.retrieval.utils import filter_source_metadata, get_content_from_url
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.routers.images import (
@ -62,14 +61,15 @@ from open_webui.routers.memories import (
update_memories as _update_memories,
)
from open_webui.routers.retrieval import search_web as _search_web
from open_webui.socket.main import sio
from open_webui.tasks import stop_item_tasks
from open_webui.socket.main import close_changed_note_documents, sio
from open_webui.tools.knowledge_fs import kb_exec # noqa: F401 — re-exported
from open_webui.utils.access_control.files import has_access_to_note_files
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.notifications import notify_target
from open_webui.utils.sanitize import sanitize_code
from open_webui.utils.skill_files import SkillFile, SkillFileOperation, skill_content_page, skill_overview
from open_webui.utils.terminals import upload_files_to_terminal
log = logging.getLogger(__name__)
@ -376,6 +376,8 @@ async def generate_image(
__event_emitter__: callable = None,
__chat_id__: str = None,
__message_id__: str = None,
__metadata__: dict = None,
__event_call__: callable = None,
) -> str:
"""
Generate an image based on a text prompt.
@ -383,7 +385,7 @@ async def generate_image(
:param prompt: A detailed description of the image to generate
:param size: Optional output size in WIDTHxHEIGHT pixels (e.g. "1536x1024"), supported by the configured image model.
Omit to use the configured default.
:return: Confirmation that the image was generated, or an error message
:return: Image URLs and, when a terminal is selected, terminal_path for display_file or further processing.
"""
if __request__ is None:
return JSONCodec.dumps({'error': 'Request context not available'})
@ -402,6 +404,10 @@ async def generate_image(
user=user,
)
await upload_files_to_terminal(
__request__, user, images, __metadata__ or {}, {'__event_call__': __event_call__}
)
# Prepare file entries for the images
image_files = [{'type': 'image', **img} for img in images]
@ -429,7 +435,10 @@ async def generate_image(
return JSONCodec.dumps(
{
'status': 'success',
'message': 'The image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.',
'message': (
'The image is already visible in chat. Use terminal_path with terminal tools or '
'display_file when available to continue the requested task.'
),
'images': images,
},
ensure_ascii=False,
@ -450,6 +459,8 @@ async def edit_image(
__event_emitter__: callable = None,
__chat_id__: str = None,
__message_id__: str = None,
__metadata__: dict = None,
__event_call__: callable = None,
) -> str:
"""
Transform one or more existing images according to a text prompt.
@ -459,7 +470,7 @@ async def edit_image(
:param image_urls: Source image URLs to modify or use as composition inputs
:param size: Optional output size in WIDTHxHEIGHT pixels (e.g. "1536x1024"), supported by the configured image model.
Omit to use the configured default.
:return: Confirmation that the images were edited, or an error message
:return: Image URLs and, when a terminal is selected, terminal_path for display_file or further processing.
"""
if __request__ is None:
return JSONCodec.dumps({'error': 'Request context not available'})
@ -478,6 +489,10 @@ async def edit_image(
user=user,
)
await upload_files_to_terminal(
__request__, user, images, __metadata__ or {}, {'__event_call__': __event_call__}
)
# Prepare file entries for the images
image_files = [{'type': 'image', **img} for img in images]
@ -505,7 +520,10 @@ async def edit_image(
return JSONCodec.dumps(
{
'status': 'success',
'message': 'The edited image has been successfully generated and is already visible to the user in the chat. You do not need to display or embed the image again - just acknowledge that it has been created.',
'message': (
'The edited image is already visible in chat. Use terminal_path with terminal tools or '
'display_file when available to continue the requested task.'
),
'images': images,
},
ensure_ascii=False,
@ -1184,10 +1202,11 @@ async def search_notes(
if end_ts and note.updated_at > end_ts:
continue
# Extract a snippet from the markdown content
# Extract a snippet from the matching page.
page = matching_page(note.data, query)
content_snippet = ''
if note.data and note.data.get('content', {}).get('md'):
md_content = note.data['content']['md']
if page['content'].get('md'):
md_content = page['content']['md']
content_lower = md_content.lower()
# Find the first matching word to center the snippet around.
@ -1217,6 +1236,9 @@ async def search_notes(
'id': note.id,
'title': note.title,
'snippet': content_snippet,
'page_id': page['id'],
'page_title': page['title'],
'url': f'/notes/{note.id}?page={page["id"]}',
'updated_at': note.updated_at,
}
)
@ -1232,6 +1254,7 @@ async def search_notes(
async def view_note(
note_id: str,
page_id: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
@ -1239,6 +1262,7 @@ async def view_note(
Get the full content of a note by its ID.
:param note_id: The ID of the note to retrieve
:param page_id: Page to read; omit to inspect the complete note and its page directory
:return: JSON with the note's id, title, and full markdown content
"""
if __request__ is None:
@ -1272,16 +1296,24 @@ async def view_note(
):
return JSONCodec.dumps({'error': 'Access denied'})
# Extract markdown content
content = ''
if note.data and note.data.get('content', {}).get('md'):
content = note.data['content']['md']
page = get_page(note.data, page_id) if page_id or len(note.data['pages']) == 1 else None
content = page['content'].get('md', '') if page else note.data['content'].get('md', '')
# Keep the version read by this tool run out of the model-facing interface.
if not hasattr(__request__.state, 'note_page_revisions'):
__request__.state.note_page_revisions = {}
for read_page in [page] if page else note.data['pages']:
__request__.state.note_page_revisions[(user_id, note.id, read_page['id'])] = read_page.get('revision', 0)
return JSONCodec.dumps(
{
'id': note.id,
'title': note.title,
'content': content,
'page_id': page['id'] if page else None,
'page_title': page['title'] if page else None,
'pages': [
{'id': p['id'], 'title': p['title'], 'parent_id': p.get('parent_id')} for p in note.data['pages']
],
'updated_at': note.updated_at,
'created_at': note.created_at,
},
@ -1322,6 +1354,9 @@ async def write_note(
access_grants=[], # Private by default - only owner can access
)
editor = await Users.get_user_by_id(user_id)
if not editor or not await has_access_to_note_files(None, get_content_files(form.data['content']), editor):
return JSONCodec.dumps({'error': 'Cannot attach files you cannot access'})
new_note = await Notes.insert_new_note(user_id, form)
if not new_note:
@ -1346,12 +1381,14 @@ async def replace_note_content(
content: Optional[str] = None,
operations: Optional[list[dict]] = None,
title: Optional[str] = None,
page_id: Optional[str] = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Update an existing note by replacing the whole markdown content, exact text, or character ranges.
Read the note with view_note before editing. If it changes meanwhile, read it again before retrying.
Prefer "replace_text" when only part of the note changes. Supply either find or start/end, not both.
find is non-empty, literal, and case-sensitive.
Text matches are non-overlapping. Multiple matches require a 0-indexed occurrence or replace_all=true;
@ -1371,6 +1408,7 @@ async def replace_note_content(
- {"action": "replace_text", "start": 0, "end": 10, "content": "...", "expected": "..."}
- {"action": "replace_text", "find": "old text", "content": "new text", "occurrence": 0, "replace_all": false}
:param title: Optional new title for the note
:param page_id: Page to edit; required for notes with multiple pages. Use the active page_id supplied in the request unless the user selects another page
:return: JSON with success status and updated note info
"""
if __request__ is None:
@ -1391,7 +1429,16 @@ async def replace_note_content(
if __user__.get('role') != 'admin' and not await _has_write_access_to_note(note, user_id):
return JSONCodec.dumps({'error': 'Write access denied', 'code': 'write_access_denied'})
current_content = ((note.data or {}).get('content') or {}).get('md') or ''
page = get_page(note.data, page_id)
page_id = page['id']
read_revisions = getattr(__request__.state, 'note_page_revisions', {})
read_key = (user_id, note.id, page_id)
if read_key not in read_revisions:
return JSONCodec.dumps({'error': 'Read this page with view_note before editing', 'code': 'read_required'})
read_revision = read_revisions[read_key]
if read_revision != page.get('revision', 0):
raise NotePageConflict('Page changed since you read it. Use view_note again before editing')
current_content = page['content'].get('md') or ''
applied_operation_count = 0
range_operations = []
if operations is not None:
@ -1539,37 +1586,40 @@ async def replace_note_content(
elif content is None:
return JSONCodec.dumps({'error': 'content or operations is required', 'code': 'content_required'})
try:
await stop_item_tasks(__request__.app.state.redis, f'note:{note_id}')
except Exception:
pass
update_data = {
'page_id': page_id,
'page_revision': read_revision,
'data': {
**(note.data or {}),
'content': {
**((note.data or {}).get('content') or {}),
'json': None,
'html': '',
'md': content,
},
}
},
}
if title:
update_data['title'] = title
editor = await Users.get_user_by_id(__user__['id'])
if not editor or not await has_access_to_note_files(
note, get_content_files(update_data['data']['content']), editor
):
return JSONCodec.dumps({'error': 'Cannot attach files you cannot access'})
form = NoteUpdateForm(**update_data)
updated_note = await Notes.update_note_by_id(note_id, form)
if not updated_note:
return JSONCodec.dumps({'error': 'Failed to update note', 'code': 'update_failed'})
read_revisions[read_key] = get_page(updated_note.data, page_id).get('revision', 0)
await close_changed_note_documents(note, updated_note)
await _emit_note_updated(__request__, __user__, updated_note)
return JSONCodec.dumps(
{
'status': 'success',
'id': updated_note.id,
'page_id': page_id,
'title': updated_note.title,
'updated_at': updated_note.updated_at,
'applied_operation_count': applied_operation_count,
@ -1577,6 +1627,8 @@ async def replace_note_content(
},
ensure_ascii=False,
)
except NotePageConflict as e:
return JSONCodec.dumps({'error': str(e), 'code': 'page_conflict'})
except Exception as e:
log.exception(f'replace_note_content error: {e}')
return JSONCodec.dumps({'error': str(e), 'code': 'unexpected_error'})
@ -1626,7 +1678,7 @@ async def search_chats(
limit=count * 3, # Fetch more for filtering
)
results = []
matched = []
for chat in chats:
# Skip the current chat to avoid showing it in search results
if __chat_id__ and chat.id == __chat_id__:
@ -1638,31 +1690,16 @@ async def search_chats(
if end_timestamp and chat.updated_at > end_timestamp:
continue
# Find a matching message snippet
snippet = ''
messages = (getattr(chat, 'chat', None) or {}).get('history', {}).get('messages', {})
if not messages:
messages = (getattr(chat, 'chat', None) or {}).get('messages', {}) or {}
if isinstance(messages, list):
messages = {str(idx): message for idx, message in enumerate(messages)}
lower_query = chat_search_content_query(query)
needles = list(dict.fromkeys([lower_query, *chat_search_terms(lower_query)])) if lower_query else []
for needle in needles:
for msg_id, msg in messages.items():
content = msg.get('content', '') if isinstance(msg, dict) else ''
if isinstance(content, str) and needle in content.lower():
idx = content.lower().find(needle)
start = max(0, idx - 50)
end = min(len(content), idx + len(needle) + 100)
snippet = (
('...' if start > 0 else '') + content[start:end] + ('...' if end < len(content) else '')
)
break
if snippet:
break
matched.append(chat)
if len(matched) >= count:
break
snippets = await Chats.get_chat_search_snippets([chat.id for chat in matched], user_id, query)
lower_query = chat_search_content_query(query)
needles = list(dict.fromkeys([lower_query, *chat_search_terms(lower_query)])) if lower_query else []
results = []
for chat in matched:
snippet = snippets.get(chat.id) or ''
title = chat.title or ''
if not snippet and any(needle in title.lower() for needle in needles):
snippet = f'Title match: {title}'
@ -1676,9 +1713,6 @@ async def search_chats(
}
)
if len(results) >= count:
break
return JSONCodec.dumps(results, ensure_ascii=False)
except Exception as e:
log.exception(f'search_chats error: {e}')
@ -3086,6 +3120,67 @@ async def view_knowledge_file(
return JSONCodec.dumps({'error': str(e)})
async def display_file(
path: str,
page: int | None = None,
inline: bool = False,
__request__: Request = None,
__user__: dict = None,
__model_knowledge__: list[dict] | None = None,
) -> str:
"""
Show a terminal, chat, or knowledge base file to the user.
After display_file succeeds, do not display the same file again or emit Markdown for it.
:param path: Terminal file path, or file_id or /api/v1/files/<file_id>/content for a chat or knowledge base file
:param page: For PDF, DOCX, and PPTX files, open the preview at this 1-based page or slide number
:param inline: Show terminal files inline instead of opening the file viewer; uploaded files always display inline
:return: JSON with the file's path, name, content type, and display state
"""
if __request__ is None:
return JSONCodec.dumps({'error': 'Request context not available', 'exists': False})
if not __user__:
return JSONCodec.dumps({'error': 'User context not available', 'exists': False})
try:
from open_webui.models.files import Files
if '/' not in path:
path = f'/api/v1/files/{path}/content'
parts = path.split('/')
if len(parts) != 6 or parts[:4] != ['', 'api', 'v1', 'files'] or parts[-1] != 'content':
return JSONCodec.dumps(
{'error': 'Use file_id or /api/v1/files/<file_id>/content for an uploaded file.', 'exists': False}
)
file = await Files.get_file_by_id(parts[4])
if not file:
return JSONCodec.dumps({'error': 'File not found', 'exists': False})
if not await _has_read_access_to_file(file, __user__, __model_knowledge__):
return JSONCodec.dumps({'error': 'File not found', 'exists': False})
file_meta = file.meta or {}
result = {
'type': 'file',
'source': 'open_webui',
'exists': True,
'displayed': True,
'path': path,
'full_path': path,
'name': file_meta.get('name') or file.filename,
'content_type': file_meta.get('content_type') or 'application/octet-stream',
'mime_type': file_meta.get('content_type') or 'application/octet-stream',
}
if page:
result['page'] = page
return JSONCodec.dumps(result, ensure_ascii=False)
except Exception as e:
log.exception(f'display_file error: {e}')
return JSONCodec.dumps({'error': str(e), 'exists': False})
async def list_knowledge(
knowledge_id: Optional[str] = None,
skip: int = 0,
@ -3496,7 +3591,9 @@ async def query_knowledge_bases(
if not embedding_function:
return JSONCodec.dumps({'error': 'Embedding function not configured'})
user_model = UserModel(**__user__)
query_embedding = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user_model)
query_embedding = await embedding_function(
query, prefix=await Config.get('rag.embedding_query_prefix'), user=user_model
)
# Min-heap of (distance, knowledge_base_id) - only holds top `count` results
top_results_heap = []
@ -3604,6 +3701,7 @@ async def view_skill(
return JSONCodec.dumps(skill, ensure_ascii=False)
from types import SimpleNamespace
from open_webui.models.skills import get_skill_snapshot
from open_webui.routers.skills import authorized_skill
from open_webui.utils.skill_files import file_summaries
@ -3651,6 +3749,7 @@ async def read_skill_file(
try:
from types import SimpleNamespace
from urllib.parse import urlencode
from open_webui.models.skills import get_skill_snapshot
from open_webui.routers.skills import authorized_skill
from open_webui.utils.skill_files import file_summaries
@ -3757,6 +3856,7 @@ async def grep_skill_files(
files = [{'path': 'SKILL.md', 'content': skill['content']}]
else:
from types import SimpleNamespace
from open_webui.models.skills import get_skill_snapshot
from open_webui.routers.skills import authorized_skill
@ -3807,10 +3907,11 @@ async def create_skill(
"""
try:
from types import SimpleNamespace
from open_webui.routers.skills import create_new_skill
from open_webui.models.skills import SkillForm
from open_webui.utils.skill_files import frontmatter
from open_webui.routers.skills import create_new_skill
from open_webui.utils.access_control import has_permission
from open_webui.utils.skill_files import frontmatter
if __request__ is None or not __user__:
raise ValueError('Request and user context required')
@ -3857,9 +3958,10 @@ async def update_skill_files(
"""
try:
from types import SimpleNamespace
from open_webui.routers.skills import authorized_skill
from open_webui.models.skills import Skills
from open_webui.events import EVENTS, publish_event
from open_webui.models.skills import Skills
from open_webui.routers.skills import authorized_skill
if __request__ is None or not __user__:
raise ValueError('Request and user context required')
@ -4127,7 +4229,7 @@ async def create_automation(
return JSONCodec.dumps({'error': 'User context not available'})
try:
from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations
from open_webui.models.automations import AutomationData, AutomationForm, Automations, AutomationTarget
from open_webui.models.users import Users
from open_webui.routers.automations import check_automation_limits
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
@ -4226,7 +4328,7 @@ async def update_automation(
return JSONCodec.dumps({'error': 'User context not available'})
try:
from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations
from open_webui.models.automations import AutomationData, AutomationForm, Automations, AutomationTarget
from open_webui.models.users import Users
from open_webui.routers.automations import check_automation_limits
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule

View file

@ -8,7 +8,7 @@ from open_webui.models.folders import FolderModel
from open_webui.models.groups import Groups
from open_webui.models.knowledge import Knowledges
from open_webui.models.models import Models
from open_webui.models.notes import NoteModel, Notes
from open_webui.models.notes import NoteModel, Notes, get_note_files
from open_webui.models.users import UserModel, Users
from sqlalchemy.ext.asyncio import AsyncSession
@ -28,14 +28,14 @@ def get_note_file_ids(entries: list[dict] | None) -> set[str]:
async def has_access_to_note_files(
note: NoteModel, entries: list[dict] | None, user: UserModel, db: AsyncSession | None = None
note: NoteModel | None, entries: list[dict] | None, user: UserModel, db: AsyncSession | None = None
) -> bool:
"""Check the editor's read access to newly attached files."""
if entries is not None and not isinstance(entries, list):
return False
# Existing attachments may belong to another editor; only additions need permission.
added_ids = get_note_file_ids(entries) - get_note_file_ids((note.data or {}).get('files'))
added_ids = get_note_file_ids(entries) - get_note_file_ids(get_note_files(note.data) if note else [])
if not added_ids or user.role == 'admin':
return True

View file

@ -133,7 +133,7 @@ def log_model_usage(request: Request, user: UserModel, model_id: str, usage: Opt
source_ip=request.client.host if request.client else None,
user_agent=request.headers.get('user-agent'),
model_id=model_id,
usage={key: usage[key] for key in ('input_tokens', 'output_tokens', 'total_tokens')},
usage={key: usage[key] for key in ('input_tokens', 'output_tokens', 'total_tokens') if key in usage},
),
extra={'event': 'model_usage', 'request_id': request_id},
)

View file

@ -384,7 +384,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
EVENTS.AUTOMATION_RUN_FAILED,
actor=user,
subject_id=automation.id,
data={'name': automation.name, 'error': error},
data={'name': automation.name, 'error': error, 'message': f'{automation.name}: {error}'},
)
return
@ -470,7 +470,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
EVENTS.AUTOMATION_RUN_FAILED,
actor=user,
subject_id=automation.id,
data={'name': automation.name, 'error': error},
data={'name': automation.name, 'error': error, 'message': f'{automation.name}: {error}'},
)
return
@ -559,7 +559,13 @@ async def execute_automation(app, automation: AutomationModel) -> None:
app,
EVENTS.AUTOMATION_RUN_FAILED,
subject_id=automation.id,
data={'name': automation.name, 'error': error, **({'chat_id': chat.id} if chat else {})},
data={
'name': automation.name,
'error': error,
'user_id': automation.user_id,
'message': f'{automation.name}: {error}',
**({'chat_id': chat.id} if chat else {}),
},
)
if isinstance(e, asyncio.CancelledError):
raise

View file

@ -87,11 +87,20 @@ async def _ws_get_images(ws, workflow, client_id, base_url, api_key):
# binary messages (previews) are silently skipped
history = (await get_history(prompt_id, base_url, api_key))[prompt_id]
status = history.get('status', {})
if status.get('status_str') == 'error':
error = next((data for kind, data in status.get('messages', []) if kind == 'execution_error'), {})
raise Exception(
f'ComfyUI {error.get("node_type", "workflow")} failed: {error.get("exception_message", "").strip()}'
)
for node_id in history['outputs']:
node_output = history['outputs'][node_id]
if node_id in workflow and workflow[node_id].get('class_type') in [
'SaveImage',
'SaveImageAdvanced',
'SaveAnimatedWEBP',
'SaveAnimatedPNG',
'PreviewImage',
]:
if 'images' in node_output:

View file

@ -1,7 +1,6 @@
import asyncio
import logging
import math
from contextlib import AsyncExitStack
from typing import Optional
from urllib.parse import urlsplit
@ -67,7 +66,7 @@ class OAuthTokenAuth(httpx2.Auth):
class MCPClient:
def __init__(self, event_caller=None, server_name=''):
self.client: Optional[Client] = None
self.exit_stack = None
self.session_task: Optional[asyncio.Task] = None
self.event_caller = event_caller
self.server_name = server_name
self.instructions: Optional[str] = None
@ -108,26 +107,42 @@ class MCPClient:
return ElicitResult(action='cancel')
async def connect(self, url: str, headers: Optional[dict] = None, auth: Optional[httpx2.Auth] = None):
async with AsyncExitStack() as exit_stack:
try:
http_client = await exit_stack.enter_async_context(create_httpx_client(headers=headers, auth=auth))
# A failed request makes the SDK cancel the task that opened the session, so it gets its own task.
connected = asyncio.get_running_loop().create_future()
self.session_task = asyncio.create_task(self.run_session(url, headers, auth, connected))
try:
await connected
except BaseException:
await self.disconnect()
raise
async def run_session(
self, url: str, headers: Optional[dict], auth: Optional[httpx2.Auth], connected: asyncio.Future
) -> None:
try:
async with create_httpx_client(headers=headers, auth=auth) as http_client:
# mode='auto' (the default) probes server/discover for the stateless
# 2026-07-28 protocol and falls back to the initialize handshake on
# legacy servers. The read timeout bounds that handshake and every
# later request that does not set its own; the SDK caps the probe itself.
self.client = await exit_stack.enter_async_context(
Client(
streamable_http_client(url, http_client=http_client),
read_timeout_seconds=MCP_INITIALIZE_TIMEOUT,
elicitation_callback=self.elicit if self.event_caller else None,
)
)
self.instructions = self.client.instructions
self.exit_stack = exit_stack.pop_all()
except Exception as e:
await self.disconnect()
raise e
async with Client(
streamable_http_client(url, http_client=http_client),
read_timeout_seconds=MCP_INITIALIZE_TIMEOUT,
elicitation_callback=self.elicit if self.event_caller else None,
) as client:
self.client = client
self.instructions = client.instructions
connected.set_result(None)
await asyncio.sleep(math.inf)
except Exception as e:
if connected.done():
log.exception("Lost connection to MCP server '%s'", self.server_name)
else:
connected.set_exception(e)
finally:
self.client = None
if not connected.done():
connected.set_exception(RuntimeError('MCP client failed to connect.'))
async def list_tool_specs(self) -> Optional[dict]:
if not self.client:
@ -242,38 +257,26 @@ class MCPClient:
This method is idempotent — calling it multiple times or on a
client that was never connected is safe.
"""
exit_stack = self.exit_stack
if exit_stack is None:
session_task = self.session_task
if session_task is None:
return
# Prevent double-close from concurrent callers
self.exit_stack = None
self.session_task = None
self.client = None
session_task.cancel()
try:
# IMPORTANT: Do NOT use asyncio.shield() or asyncio.wait_for()
# because they create a new asyncio task, which violates the MCP SDK's
# requirement that its TaskGroup be exited in the exact same task.
# ALSO do NOT use anyio.CancelScope(shield=True) or anyio.fail_after(),
# because they push a new cancel scope onto the task, violating LIFO
# order when aclose() attempts to exit the inner TaskGroup.
# We simply call aclose() directly. If the task is cancelled, the
# sockets will eventually be cleaned up by garbage collection.
await exit_stack.aclose()
except asyncio.CancelledError as exc:
await session_task
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling():
raise
log.debug('MCPClient.disconnect() suppressed internal cancellation: %s', exc)
except RuntimeError as exc:
log.debug('MCPClient.disconnect() suppressed RuntimeError: %s', exc)
except Exception as exc:
log.debug('MCPClient.disconnect() error: %s', exc)
async def __aenter__(self):
await self.exit_stack.__aenter__()
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
await self.disconnect()

View file

@ -52,7 +52,6 @@ from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.models import Models
from open_webui.models.notes import Notes
from open_webui.models.users import UserModel, Users
from open_webui.retrieval.utils import filter_source_metadata, get_sources_from_items
from open_webui.routers.images import (
@ -135,6 +134,7 @@ from open_webui.utils.response import (
handle_responses_streaming_event,
merge_usage,
normalize_usage,
update_usage_snapshot,
)
from open_webui.utils.sanitize import sanitize_code
from open_webui.utils.skills import (
@ -150,11 +150,14 @@ from open_webui.utils.task import (
rag_template,
tools_function_calling_generation_template,
)
from open_webui.utils.terminals import upload_files_to_terminal
from open_webui.utils.tool_approval import complete_tool_call, pending_tool_calls
from open_webui.utils.tool_search import apply_tool_search, resolve_tool_call
from open_webui.utils.tools import (
connect_mcp_server,
display_file,
get_attached_knowledge,
get_builtin_tool_spec,
get_builtin_tools,
get_terminal_tools,
get_tools,
@ -331,7 +334,8 @@ def build_terminal_file_tool_result(
**tool_result,
'type': 'file',
'source': 'open_terminal',
**({'displayed': True} if tool_function_params.get('inline') is True else {}),
'exists': True,
'displayed': tool_function_params.get('inline') is True,
'terminal_selector': terminal_selector,
**({'terminal_id': terminal_id} if terminal_id else {}),
**({'terminal_url': server_url} if server_url and not terminal_id else {}),
@ -969,8 +973,8 @@ async def terminal_event_handler(
path = result.get('path') or tool_function_params.get('path', '')
if not path:
return
# Only emit if the file actually exists
if result.get('exists') is False:
# Only open the viewer for existing files that are not displayed inline.
if result.get('exists') is False or result.get('displayed') is True:
return
page = tool_function_params.get('page')
@ -1495,6 +1499,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
'message_id': metadata.get('message_id', None),
**({'channel_id': chat_id.removeprefix('channel:')} if is_channel_chat else {'chat_id': chat_id}),
}
terminal_metadata = {**metadata, 'terminal_id': form_data.get('terminal_id')}
if not is_saved_chat_id(chat_id):
message_list = form_data.get('messages', [])
@ -1543,6 +1548,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
metadata=image_metadata,
user=user,
)
await upload_files_to_terminal(request, user, images, terminal_metadata, extra_params)
await __event_emitter__(
{
@ -1566,7 +1572,10 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
}
)
system_message_content = '<context>The requested image has been edited and created and is now being shown to the user. Let them know that it has been generated.</context>'
system_message_content = (
'<context>The edited images are shown in chat. Use terminal_path with terminal tools or '
f'display_file when available. Images: {JSONCodec.dumps(images)}</context>'
)
except Exception as e:
log.debug(e)
@ -1651,6 +1660,7 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
metadata=image_metadata,
user=user,
)
await upload_files_to_terminal(request, user, images, terminal_metadata, extra_params)
await __event_emitter__(
{
@ -1674,7 +1684,10 @@ async def chat_image_generation_handler(request: Request, form_data: dict, extra
}
)
system_message_content = '<context>The requested image has been created by the system successfully and is now being shown to the user. Let the user know that the image they requested has been generated and is now shown in the chat.</context>'
system_message_content = (
'<context>The generated images are shown in chat. Use terminal_path with terminal tools or '
f'display_file when available. Images: {JSONCodec.dumps(images)}</context>'
)
except Exception as e:
log.debug(e)
@ -1929,6 +1942,22 @@ def strip_reasoning_details(output: list) -> list:
]
def strip_tool_images(output: list, model: dict) -> list:
"""Replace tool result images with a text note when the model has Vision turned off."""
if (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('vision', True):
return output
stripped_output = []
for item in output:
if isinstance(item, dict) and item.get('type') == 'function_call_output':
parts = [
{'type': 'input_text', 'text': '\n[image]'} if part.get('type') == 'input_image' else part
for part in item.get('output', [])
]
item = {**item, 'output': parts}
stripped_output.append(item)
return stripped_output
def process_messages_with_output(
messages: list[dict],
reasoning_format: str | None = None,
@ -2182,6 +2211,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# for output produced by the same model.
if message.get('role') == 'assistant' and message.get('model') != model['id'] and isinstance(output, list):
message['output'] = strip_reasoning_details(output)
if message.get('role') == 'assistant' and isinstance(output, list):
message['output'] = strip_tool_images(message['output'], model)
form_data['messages'] = process_messages_with_output(
form_data.get('messages', []),
@ -2475,28 +2506,6 @@ async def process_chat_payload(request, form_data, user, metadata, model):
activated_skill_ids = mentioned_skill_ids
available_skills = []
terminal_skills = []
if is_note_chat:
note_id = (chat.meta or {}).get('note_id')
note = await Notes.get_note_by_id(note_id) if note_id else None
if note and (
user.role == 'admin'
or note.user_id == user.id
or await AccessGrants.has_access(
user_id=user.id,
resource_type='note',
resource_id=note.id,
permission='read',
)
):
note_files = [
file
for file in ((note.data or {}).get('files') or [])
if isinstance(file, dict)
and file.get('type') != 'image'
and not (file.get('content_type') or '').startswith('image/')
]
if note_files:
files = [*(files or []), *note_files]
model_builtin_tools = model.get('info', {}).get('meta', {}).get('builtinTools') or {}
@ -2804,7 +2813,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
for tool in tool_specs:
tools_dict[tool['name']] = {
'spec': tool,
'spec': (
get_builtin_tool_spec(display_file)
if tool_server.get('is_terminal') and tool['name'] == 'display_file'
else tool
),
'direct': True,
'server': tool_server,
}
@ -2858,6 +2871,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
for name, tool_dict in builtin_tools.items():
if name not in tools_dict:
tools_dict[name] = tool_dict
elif name == 'display_file' and (
tools_dict[name].get('type') == 'terminal'
or tools_dict[name].get('server', {}).get('is_terminal') is True
):
tools_dict[name] = {**tools_dict[name], 'file_tool': tool_dict}
# Connections can keep shell tools advertised across browser disconnects for prompt caching.
shell_tools = {
@ -3002,6 +3020,8 @@ async def process_chat_payload(request, form_data, user, metadata, model):
assistant_message = dict(pending_assistant_message)
if assistant_message.get('model') != model['id'] and isinstance(assistant_message.get('output'), list):
assistant_message['output'] = strip_reasoning_details(assistant_message['output'])
if isinstance(assistant_message.get('output'), list):
assistant_message['output'] = strip_tool_images(assistant_message['output'], model)
form_data['messages'].extend(
process_messages_with_output([assistant_message], reasoning_format=get_reasoning_format(model))
)
@ -3069,11 +3089,9 @@ async def build_chat_response_context(request, form_data, user, model, metadata,
async def execute_tool_call(form_data, metadata, event_caller, tool_call):
error = resolve_tool_call(tool_call, metadata.get('tools', {}))
if error:
return {}, error, None, None, False
name = tool_call.get('function', {}).get('name', '')
tool_args = tool_call.get('function', {}).get('arguments', '{}')
"""Execute the saved function_call item, already resolved before approval staging."""
name = tool_call.get('name', '')
tool_args = tool_call.get('arguments', '{}')
params = {}
if tool_args and tool_args.strip():
try:
@ -3083,7 +3101,7 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
return {}, None, None, None, False
return {}, 'Error: Tool arguments are not valid JSON.', None, None, False
if not isinstance(params, dict):
return (
{},
@ -3092,11 +3110,19 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
None,
False,
)
tool_call.setdefault('function', {})['arguments'] = JSONCodec.dumps(params)
tool_call['arguments'] = JSONCodec.dumps(params)
tool = metadata.get('tools', {}).get(name)
if not tool:
return params, f'Error: Tool "{name}" not found.', None, None, False
if name == 'display_file' and (
str(params.get('path', '')).startswith('/api/v1/files/')
or re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', str(params.get('path', '')))
):
if tool.get('file_tool'):
tool = tool['file_tool']
elif tool.get('type') == 'terminal' or tool.get('server', {}).get('is_terminal') is True:
return params, 'Error: Uploaded file display is not available.', None, None, False
tool_type = tool.get('type', '')
direct_tool = tool.get('direct', False)
# MCP servers validate their own arguments
@ -3114,7 +3140,7 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
or await event_caller(
{
'type': 'confirmation',
'data': {'tool_call': {'id': tool_call.get('id'), 'name': name, 'arguments': params}},
'data': {'tool_call': {'id': tool_call.get('call_id'), 'name': name, 'arguments': params}},
}
)
is not True # Disconnection and timeout replies are error objects, not approval.
@ -3122,31 +3148,37 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
):
return params, 'Error: tool call was not approved.', tool, tool_type, direct_tool
if direct_tool:
if not event_caller:
result = 'Error: Browser session is not connected for this direct tool.'
tool_call['started_at'] = time.time()
started = time.perf_counter()
try:
if direct_tool:
if not event_caller:
result = 'Error: Browser session is not connected for this direct tool.'
else:
result = await event_caller(
{
'type': 'execute:tool',
'data': {
'id': str(uuid4()),
'name': name,
'params': params,
'server': tool.get('server', {}),
'session_id': metadata.get('session_id'),
},
}
)
else:
result = await event_caller(
{
'type': 'execute:tool',
'data': {
'id': str(uuid4()),
'name': name,
'params': params,
'server': tool.get('server', {}),
'session_id': metadata.get('session_id'),
},
}
function = await get_updated_tool_function(
function=tool['callable'],
extra_params={
'__messages__': form_data.get('messages', []),
'__files__': metadata.get('files') or [],
},
)
else:
function = await get_updated_tool_function(
function=tool['callable'],
extra_params={
'__messages__': form_data.get('messages', []),
'__files__': metadata.get('files') or [],
},
)
result = await function(**params)
result = await function(**params)
finally:
tool_call['ended_at'] = time.time()
tool_call['duration'] = time.perf_counter() - started
except Exception as e:
result = {'error': str(e)}
return params, result, tool, tool_type, direct_tool
@ -3229,17 +3261,9 @@ async def resume_tool_calls(request, form_data, user, model, metadata, message)
saved['done'] = False
item = copy.deepcopy(item)
tool_call = {
'id': item.get('call_id', ''),
'type': 'function',
'function': {
'name': item.get('name', ''),
'arguments': item.get('arguments', '{}'),
},
}
try:
params, result, tool, tool_type, direct_tool = await execute_tool_call(
form_data, metadata, event_caller, tool_call
form_data, metadata, event_caller, item
)
files, embeds = [], []
if result is None and tool is None:
@ -3264,7 +3288,6 @@ async def resume_tool_calls(request, form_data, user, model, metadata, message)
):
await event_emitter({'type': 'source', 'data': source})
content = tool_result_content(result)
item['arguments'] = tool_call.get('function', {}).get('arguments', '{}')
output_parts = [{'type': 'input_text', 'text': content}]
item['status'] = 'failed' if _is_tool_result_error(content) else 'completed'
display_files = []
@ -3280,7 +3303,7 @@ async def resume_tool_calls(request, form_data, user, model, metadata, message)
result_item = {
'type': 'function_call_output',
'id': output_id('fco'),
'call_id': tool_call['id'],
'call_id': item['call_id'],
'output': output_parts,
'status': item['status'],
**({'files': display_files} if display_files else {}),
@ -3297,7 +3320,7 @@ async def resume_tool_calls(request, form_data, user, model, metadata, message)
{
'type': 'function_call_output',
'id': output_id('fco'),
'call_id': tool_call['id'],
'call_id': item['call_id'],
'status': 'incomplete',
'output': [
{
@ -3450,13 +3473,13 @@ def update_assistant_message_from_stream(assistant_message, raw) -> bool:
if output:
assistant_message['output'] = output
if meta and meta.get('usage'):
assistant_message['usage'] = merge_usage(assistant_message.get('usage'), meta['usage'])
assistant_message['usage'] = update_usage_snapshot(assistant_message.get('usage'), meta['usage'])
continue
raw_usage = data.get('usage', {}) or {}
raw_usage.update(data.get('timings', {}))
if raw_usage:
assistant_message['usage'] = merge_usage(assistant_message.get('usage'), raw_usage)
assistant_message['usage'] = update_usage_snapshot(assistant_message.get('usage'), raw_usage)
for choice in data.get('choices', []):
if choice.get('finish_reason'):
@ -3948,6 +3971,7 @@ async def non_streaming_chat_response_handler(response, ctx):
{
'error': {'content': error},
'done': True,
'meta': {'response_ended_at': time.time()},
},
)
if isinstance(error, str) or isinstance(error, dict):
@ -4067,6 +4091,10 @@ async def non_streaming_chat_response_handler(response, ctx):
# Save message in the database
usage = normalize_usage(response_data.get('usage', {}) or {})
if continuing:
usage = merge_usage(
(ctx.get('assistant_message') or {}).get('usage'), usage or {'incomplete': True}
)
finish_reason = choices[0].get('finish_reason') if choices else None
if save_to_chat:
@ -4075,6 +4103,7 @@ async def non_streaming_chat_response_handler(response, ctx):
metadata['message_id'],
{
'done': True,
'meta': {'response_ended_at': time.time()},
'role': 'assistant',
'output': response_output,
**({'usage': usage} if usage else {}),
@ -4512,7 +4541,7 @@ async def streaming_chat_response_handler(response, ctx):
prior_output, output = output, []
content_parts = []
usage = None
usage = normalize_usage((message or {}).get('usage') or {}) if existing_output else None
finish_reason = None
last_response_id = None
@ -4655,6 +4684,7 @@ async def streaming_chat_response_handler(response, ctx):
nonlocal stream_size
response_tool_calls = []
prior_usage = usage
current_usage = {}
form_data['model'] = current_form_data['model']
@ -4881,8 +4911,10 @@ async def streaming_chat_response_handler(response, ctx):
# Normalize and capture usage for DB persistence
if response_metadata.get('usage'):
current_usage = response_metadata['usage']
usage = merge_usage(usage, response_metadata['usage'])
current_usage = update_usage_snapshot(
current_usage, response_metadata['usage']
)
usage = merge_usage(prior_usage, current_usage)
response_metadata['usage'] = usage
if response_metadata.get('error'):
@ -4924,8 +4956,8 @@ async def streaming_chat_response_handler(response, ctx):
raw_usage = data.get('usage', {}) or {}
raw_usage.update(data.get('timings', {})) # llama.cpp
if raw_usage:
current_usage = raw_usage
usage = merge_usage(usage, raw_usage)
current_usage = update_usage_snapshot(current_usage, raw_usage)
usage = merge_usage(prior_usage, current_usage)
await event_emitter(
{
'type': 'chat:completion',
@ -5567,6 +5599,8 @@ async def streaming_chat_response_handler(response, ctx):
tool_calls.append(_split_tool_calls(responses_api_tool_calls))
await save_current_response_stream()
if not current_usage:
usage = {**(usage or {}), 'incomplete': True}
return normalize_usage(current_usage)
output_start = len(prior_output)
@ -5728,20 +5762,18 @@ async def streaming_chat_response_handler(response, ctx):
results = []
delegate_calls = [
tool_call
for tool_call in response_tool_calls
if tool_call.get('function', {}).get('name') == 'delegate_task'
]
# Provider calls have been resolved and staged; execute their saved items directly.
response_tool_calls = [output_by_call_id[call['id']] for call in response_tool_calls]
delegate_calls = [call for call in response_tool_calls if call.get('name') == 'delegate_task']
tool_results = {}
for tool_call in response_tool_calls:
if tool_call.get('function', {}).get('name') != 'delegate_task':
tool_results[id(tool_call)] = await execute_tool_call(
if tool_call.get('name') != 'delegate_task':
tool_results[tool_call['call_id']] = await execute_tool_call(
form_data, metadata, event_caller, tool_call
)
tool_results.update(
zip(
[id(tool_call) for tool_call in delegate_calls],
[tool_call['call_id'] for tool_call in delegate_calls],
await asyncio.gather(
*(
execute_tool_call(form_data, metadata, event_caller, tool_call)
@ -5752,9 +5784,11 @@ async def streaming_chat_response_handler(response, ctx):
)
for tool_call in response_tool_calls:
tool_call_id = tool_call.get('id', '')
tool_function_name = tool_call.get('function', {}).get('name', '')
tool_function_params, tool_result, tool, tool_type, direct_tool = tool_results[id(tool_call)]
tool_call_id = tool_call['call_id']
tool_function_name = tool_call.get('name', '')
tool_function_params, tool_result, tool, tool_type, direct_tool = tool_results[
tool_call['call_id']
]
if tool_result is None:
results.append(
{
@ -5848,14 +5882,8 @@ async def streaming_chat_response_handler(response, ctx):
}
)
# Update function_call statuses and parsed/sanitized arguments.
for tc in response_tool_calls:
call_id = tc.get('id', '')
for item in output:
if item.get('type') == 'function_call' and item.get('call_id') == call_id:
item['status'] = result_status_by_call_id.get(call_id, 'completed')
item['arguments'] = tc.get('function', {}).get('arguments', '{}')
break
for item in response_tool_calls:
item['status'] = result_status_by_call_id.get(item['call_id'], 'completed')
# Emit citation sources to the frontend for display
if citations_enabled:
@ -5929,12 +5957,12 @@ async def streaming_chat_response_handler(response, ctx):
new_form_data['messages'] = (
[system_message] if system_message else []
) + convert_output_to_messages(
output, raw=True, reasoning_format=get_reasoning_format(model)
strip_tool_images(output, model), 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,
strip_tool_images(output, model),
raw=True,
reasoning_format=get_reasoning_format(model),
flatten_tool_images=True,
@ -6259,7 +6287,7 @@ async def streaming_chat_response_handler(response, ctx):
'messages': [
*messages,
*convert_output_to_messages(
output,
strip_tool_images(output, model),
raw=True,
reasoning_format=get_reasoning_format(model),
flatten_tool_images=True,
@ -6324,6 +6352,7 @@ async def streaming_chat_response_handler(response, ctx):
metadata['message_id'],
{
'done': True,
'meta': {'response_ended_at': time.time()},
'output': current_output,
**({'usage': usage} if usage else {}),
},

View file

@ -386,17 +386,6 @@ def convert_output_to_messages(
pending_reasoning_details = []
pending_tool_image_urls = []
pending_tool_outputs = []
completed_call_ids = {
item.get('call_id')
for item in output
if item.get('type') == 'function_call'
and item.get('call_id')
and item.get('status') in {'completed', 'failed', 'rejected'}
}
result_call_ids = {
item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id')
}
function_call_ids = completed_call_ids & result_call_ids
def flush_pending():
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
@ -448,6 +437,11 @@ def convert_output_to_messages(
if not pending_tool_outputs:
return
pending_tool_outputs = [
result
for result in pending_tool_outputs
if any(call['id'] == result.get('call_id') for call in pending_tool_calls)
]
flush_pending()
for output_item in pending_tool_outputs:
output_parts = output_item.get('output', [])
@ -510,7 +504,7 @@ def convert_output_to_messages(
pending_content.append(text)
elif item_type == 'function_call':
if item.get('call_id') not in function_call_ids:
if not item.get('call_id') or item.get('status') not in {'completed', 'failed', 'rejected'}:
continue
# Collect tool calls to batch into assistant message
@ -523,9 +517,6 @@ def convert_output_to_messages(
)
elif item_type == 'function_call_output':
if item.get('call_id') not in function_call_ids:
continue
pending_tool_outputs.append(item)
elif item_type == 'reasoning':

View file

@ -109,7 +109,7 @@ async def _load_notifications(user_id: str) -> dict[str, Any]:
).strip()
if not isinstance(targets, list) or not targets:
if legacy_url:
if legacy_url and not notifications.get('legacy_notification_events_migrated'):
target = _normalize_target(
{
'id': DEFAULT_TARGET_ID,
@ -129,9 +129,10 @@ async def _load_notifications(user_id: str) -> dict[str, Any]:
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
else:
notifications['targets'] = [target for target in targets if isinstance(target, dict)]
notifications.setdefault(
'default_target_id', notifications['targets'][0].get('id') if notifications['targets'] else None
)
if not notifications.get('default_target_id'):
notifications['default_target_id'] = (
notifications['targets'][0].get('id') if notifications['targets'] else None
)
if legacy_url and not notifications.get('legacy_notification_events_migrated'):
changed = False
for target in notifications['targets']:
@ -172,7 +173,8 @@ async def create_target(user_id: str, payload: dict[str, Any]) -> dict[str, Any]
suffix += 1
targets.append(target)
notifications['targets'] = targets
notifications.setdefault('default_target_id', target['id'])
if not notifications.get('default_target_id'):
notifications['default_target_id'] = target['id']
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
return _public_target(target, notifications.get('default_target_id'))

View file

@ -83,7 +83,7 @@ from open_webui.models.config import Config
from open_webui.models.groups import GroupForm, GroupModel, Groups, GroupUpdateForm
from open_webui.models.oauth_sessions import OAuthSessions
from open_webui.models.users import Users
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
from open_webui.retrieval.web.utils import assert_identity_encoding, get_ssrf_safe_session, validate_url
from open_webui.utils.auth import (
get_optional_verified_user_from_request,
get_password_hash,
@ -1878,20 +1878,19 @@ class OAuthManager:
try:
await asyncio.to_thread(validate_url, picture_url)
get_kwargs = {}
headers = {'Accept-Encoding': 'identity'}
if access_token:
get_kwargs['headers'] = {
'Authorization': f'Bearer {access_token}',
}
headers['Authorization'] = f'Bearer {access_token}'
# get_ssrf_safe_session pins the connect-time IP (defeats DNS rebinding); allow_redirects=False keeps validate_url's vet authoritative.
async with get_ssrf_safe_session() as session:
async with session.get(
picture_url,
**get_kwargs,
headers=headers,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
) as resp:
if resp.ok:
assert_identity_encoding(resp.headers.getall('Content-Encoding', ()))
upstream_mime = (resp.headers.get('Content-Type', '') or '').split(';', 1)[0].strip().lower()
picture = await resp.read()
base64_encoded_picture = base64.b64encode(picture).decode('utf-8')

View file

@ -8,42 +8,42 @@ from open_webui.utils.misc import (
)
# An honest ledger is worth more than a flattering one.
# Let every cost here be counted true.
def normalize_usage(usage: dict) -> dict:
"""
Normalize usage statistics to standard format.
Handles OpenAI, Ollama, and llama.cpp formats.
def usage_counter(value):
if value is None or isinstance(value, bool):
return None
try:
value = int(value)
return value if value >= 0 else None
except (ValueError, TypeError, OverflowError):
return None
Adds standardized token fields to the original data:
- input_tokens: Number of tokens in the prompt
- output_tokens: Number of tokens generated
- total_tokens: Sum of input and output tokens
"""
def normalize_usage(usage: dict) -> dict:
"""Keep measured zero distinct from missing usage across provider formats."""
if not usage:
return {}
# Map various field names to standard names
input_tokens = usage.get('input_tokens') or usage.get('prompt_tokens') or usage.get('prompt_eval_count')
if input_tokens is None:
input_tokens = int(usage.get('prompt_n') or 0) + int(usage.get('cache_n') or 0)
output_tokens = (
usage.get('output_tokens') # Already standard
or usage.get('completion_tokens') # OpenAI
or usage.get('eval_count') # Ollama
or usage.get('predicted_n') # llama.cpp
or 0
)
total_tokens = usage.get('total_tokens') or (input_tokens + output_tokens)
# Add standardized fields to original data
result = dict(usage)
result['input_tokens'] = int(input_tokens)
result['output_tokens'] = int(output_tokens)
result['total_tokens'] = int(total_tokens)
aliases = {
'input_tokens': ('input_tokens', 'prompt_tokens', 'prompt_eval_count'),
'output_tokens': ('output_tokens', 'completion_tokens', 'eval_count', 'predicted_n'),
'total_tokens': ('total_tokens',),
}
for target, keys in aliases.items():
value = next((n for key in keys if (n := usage_counter(usage.get(key))) is not None), None)
result.pop(target, None)
if value is not None:
result[target] = value
prompt = usage_counter(usage.get('prompt_n'))
if 'input_tokens' not in result and prompt is not None:
result['input_tokens'] = prompt + (usage_counter(usage.get('cache_n')) or 0)
if 'total_tokens' not in result and all(key in result for key in ('input_tokens', 'output_tokens')):
result['total_tokens'] = result['input_tokens'] + result['output_tokens']
# SQL fallback readers must not turn invalid aliases into zero or fail a cast.
for key in ('prompt_tokens', 'completion_tokens'):
value = usage_counter(usage.get(key))
result.pop(key, None)
if value is not None:
result[key] = value
return result
@ -51,6 +51,8 @@ USAGE_TOKEN_KEYS = {
'input_tokens',
'output_tokens',
'total_tokens',
'cache_read_input_tokens',
'cache_creation_input_tokens',
}
USAGE_COST_KEYS = {
@ -112,6 +114,13 @@ def merge_usage(current: dict | None, incoming: dict | None) -> dict:
return incoming_usage
result = {**current_usage, **incoming_usage}
result['incomplete'] = bool(
current_usage.get('incomplete')
or incoming_usage.get('incomplete')
or any(
key not in record for record in (current_usage, incoming_usage) for key in ('input_tokens', 'output_tokens')
)
)
for key in USAGE_SUMMABLE_KEYS:
if key in current_usage or key in incoming_usage:
@ -129,20 +138,25 @@ def merge_usage(current: dict | None, incoming: dict | None) -> dict:
incoming_usage.get(key) if isinstance(incoming_usage.get(key), dict) else {},
)
result['prompt_tokens'] = (
incoming_usage.get('prompt_tokens')
or incoming_usage.get('input_tokens')
or current_usage.get('prompt_tokens', 0)
)
result['completion_tokens'] = (
incoming_usage.get('completion_tokens')
or incoming_usage.get('output_tokens')
or current_usage.get('completion_tokens', 0)
)
# Aliases keep the latest call's size, which context compaction reads.
for alias, canonical in (('prompt_tokens', 'input_tokens'), ('completion_tokens', 'output_tokens')):
if canonical in incoming_usage:
result[alias] = incoming_usage[canonical]
return result
def update_usage_snapshot(current: dict | None, incoming: dict | None) -> dict:
"""Usage events within one provider call are snapshots, not extra model calls."""
result = {**(current or {}), **normalize_usage(incoming or {})}
for key in USAGE_DETAIL_KEYS:
if isinstance((current or {}).get(key), dict) and isinstance((incoming or {}).get(key), dict):
result[key] = {**current[key], **incoming[key]}
if all(key in result for key in ('input_tokens', 'output_tokens')):
result['total_tokens'] = result['input_tokens'] + result['output_tokens']
return result
def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
openai_tool_calls = []
for tool_call in tool_calls:
@ -161,11 +175,11 @@ def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
def convert_ollama_usage_to_openai(data: dict) -> dict:
input_tokens = int(data.get('prompt_eval_count', 0))
output_tokens = int(data.get('eval_count', 0))
input_tokens = usage_counter(data.get('prompt_eval_count')) or 0
output_tokens = usage_counter(data.get('eval_count')) or 0
total_tokens = input_tokens + output_tokens
return {
result = {
# Standardized fields
'input_tokens': input_tokens,
'output_tokens': output_tokens,
@ -199,13 +213,23 @@ def convert_ollama_usage_to_openai(data: dict) -> dict:
'approximate_total': (lambda s: f'{s // 3600}h{(s % 3600) // 60}m{s % 60}s')(
(data.get('total_duration', 0) or 0) // 1_000_000_000
),
'completion_tokens_details': {
'reasoning_tokens': 0,
'accepted_prediction_tokens': 0,
'rejected_prediction_tokens': 0,
},
}
for source, fields in (
('prompt_eval_count', ('input_tokens', 'prompt_tokens', 'prompt_eval_count')),
('eval_count', ('output_tokens', 'completion_tokens', 'eval_count')),
):
if usage_counter(data.get(source)) is None:
for field in fields:
result.pop(field, None)
result.pop('total_tokens', None)
for key in ('total_duration', 'load_duration', 'prompt_eval_duration', 'eval_duration'):
if key not in data:
result.pop(key, None)
if 'total_duration' not in data:
result.pop('approximate_total', None)
return result
def convert_response_ollama_to_openai(ollama_response: dict) -> dict:
model = ollama_response.get('model', 'ollama')

View file

@ -0,0 +1,69 @@
"""Interpret saved records without inventing missing measurements."""
import json
import math
import re
import time
def recorded_timestamp(value):
try:
value = float(value)
if value > 10_000_000_000:
value /= 1000
return int(value) if math.isfinite(value) and 1577836800 <= value <= time.time() + 86400 else None
except (ValueError, TypeError, OverflowError):
return None
def safe_snippet(value):
"""Analytics previews deliberately exclude raw payloads and identifiers."""
if not value:
return None
text = value if isinstance(value, str) else json.dumps(value)
text = re.sub(r'<[^>]*>', ' ', text)
text = re.sub(r'https?://\S+|(?:[\w.-]+/)+[\w./~-]*|\b[\w-]{24,}\b', ' ', text)
text = re.sub(r'(?i)\b(?:api[_-]?key|token|password|secret|authorization)\b[\"\x27]?\s*[:=]?\s*\S+', ' ', text)
return ' '.join(text.split()[:10])
def normalize_tool_output(output):
"""Deduplicate recorded call/result snapshots; never construct calls from prose."""
if not isinstance(output, list):
return output
items = [dict(item) for item in output if isinstance(item, dict)]
normalized = []
indices = {}
start = 0
for item in items:
item_type = item.get('type')
# A new assistant item after tool results starts a new round.
if normalized and normalized[-1].get('type') == 'function_call_output' and item_type != 'function_call_output':
pair_results(normalized[start:])
indices.clear()
start = len(normalized)
call_id = item.get('call_id') or item.get('id')
if item_type in {'function_call', 'function_call_output'} and call_id:
key = (item_type, call_id)
if key in indices:
normalized[indices[key]] = {**normalized[indices[key]], **item}
continue
indices[key] = len(normalized)
normalized.append(item)
pair_results(normalized[start:])
return normalized
def pair_results(output):
results = {item.get('call_id'): item for item in output if item.get('type') == 'function_call_output'}
for item in output:
if item.get('type') != 'function_call':
continue
result = results.get(item.get('call_id'))
if result:
for key in ('started_at', 'ended_at', 'duration', 'error_type'):
if key in result and key not in item:
item[key] = result[key]
elif item.get('status') == 'completed':
item['status'] = 'unknown'
return output

View file

@ -17,7 +17,7 @@ from open_webui.models.users import UserModel, Users
from open_webui.tasks import create_task, has_active_tasks
from open_webui.utils.auth import VERIFIED_USER_ROLES, create_token
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import add_or_update_system_message, get_message_list
from open_webui.utils.misc import add_or_update_system_message, get_message_list, is_raster_image_content_type
from open_webui.utils.tool_search import strip_deferred_tools_manifest
from starlette.datastructures import Headers
@ -343,30 +343,36 @@ async def delegate(
return 'Error: model context is required.'
if run.get('direct'):
return 'Error: sub-agents are unavailable for direct connections.'
delegated_files = []
if file_ids:
requested_file_ids = {str(file_id) for file_id in file_ids if file_id}
run['files'] = [
copy.deepcopy(file)
for file in metadata.get('files') or []
if str(file.get('id') or '') in requested_file_ids
or str(file.get('url') or '') in requested_file_ids
or (isinstance(file.get('file'), dict) and str(file.get('file', {}).get('id') or '') in requested_file_ids)
chat_messages = await Chats.get_messages_map_by_chat_id(parent_chat_id) or {}
# Photos are left out of the request's files, so also look on the chat's messages.
attached_files = [
*(metadata.get('files') or []),
*(
file
for message in chat_messages.values()
if message.get('role') == 'user'
for file in message.get('files') or []
if file.get('type') == 'image' or is_raster_image_content_type(file.get('content_type'))
),
]
found_file_ids = {
str(value)
for file in run['files']
for value in (
file.get('id'),
file.get('url'),
file.get('file', {}).get('id') if isinstance(file.get('file'), dict) else None,
)
if value
}
found_file_ids = set()
for file in attached_files:
nested_file = file.get('file') if isinstance(file.get('file'), dict) else {}
keys = {str(value) for value in (file.get('id'), file.get('url'), nested_file.get('id')) if value}
if keys & requested_file_ids and not keys & found_file_ids:
delegated_files.append(copy.deepcopy(file))
found_file_ids |= keys
missing_file_ids = sorted(requested_file_ids - found_file_ids)
if missing_file_ids:
return f'Error: file_ids not attached or unavailable: {", ".join(missing_file_ids)}'
else:
run['files'] = []
run['files'] = [
file
for file in delegated_files
if file.get('type') != 'image' and not is_raster_image_content_type(file.get('content_type'))
]
delegation_id = f'deleg_{uuid4().hex[:8]}'
foreground_semaphore = None
@ -391,7 +397,7 @@ async def delegate(
user_message_id = str(uuid4())
assistant_message_id = str(uuid4())
prompt = f'{task}\n\n## Context\n{context}' if context else task
prompt_files = copy.deepcopy(run.get('files') or [])
prompt_files = delegated_files
user_message = {
'id': user_message_id,
'parentId': None,
@ -520,6 +526,8 @@ async def delegate(
if len(summary) > max_output:
summary = f'{summary[:max_output]}\n\n[output truncated]'
error = message.get('error')
if isinstance(error, dict):
error = error.get('content') or 'Sub-agent failed.'
return {
'status': 'error' if error else 'completed',
'summary': summary or ('Sub-agent produced no output.' if not error else ''),
@ -707,5 +715,8 @@ async def delegate(
return f'Error: {exc}'
if result.get('status') != 'completed':
return f'Error: {result.get("error") or "sub-agent failed."}'
error_text = f'Error: {result.get("error") or "sub-agent failed."}'
if result.get('summary'):
return f'{error_text}\nPartial output:\n{result["summary"]}'
return error_text
return result.get('summary') or 'Sub-agent produced no output.'

View file

@ -41,12 +41,14 @@ from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
OTLPMetricExporter as OTLPHttpMetricExporter,
)
from opentelemetry.instrumentation.asgi import get_host_port_url_tuple
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import (
PeriodicExportingMetricReader,
)
from opentelemetry.sdk.metrics.view import View
from opentelemetry.sdk.resources import Resource
from opentelemetry.util.http import get_excluded_urls
from sqlalchemy import Engine, func, select
from sqlalchemy.orm import Session
@ -222,9 +224,16 @@ def setup_metrics(app: FastAPI, resource: Resource, db_engine: Engine) -> None:
callbacks=[observe_users_active_today],
)
# OTEL_PYTHON_FASTAPI_EXCLUDED_URLS, shared with traces
excluded_urls = get_excluded_urls('FASTAPI')
# FastAPI middleware
@app.middleware('http')
async def _metrics_middleware(request: Request, call_next):
_, _, url = get_host_port_url_tuple(request.scope)
if excluded_urls.url_disabled(url):
return await call_next(request)
start_time = time.perf_counter()
status_code = None

View file

@ -5,9 +5,10 @@ import hashlib
import logging
import ntpath
import posixpath
from pathlib import Path
from urllib.parse import quote
from open_webui.env import ENABLE_TOOL_SERVERS
from open_webui.env import AIOHTTP_CLIENT_TIMEOUT_TERMINAL, ENABLE_TOOL_SERVERS
from open_webui.utils.chat_id import is_saved_chat_id
from open_webui.utils.skill_files import (
SKILL_CONTENT_MAX_CHARS,
@ -129,24 +130,23 @@ def terminal_chat_uploads(connection: dict) -> str:
return value if value in TERMINAL_CHAT_UPLOAD_MODES else 'default'
async def get_terminal_json(request, user, metadata: dict, path: str, extra_params: dict | None = None):
"""Read from an admin terminal on the backend or a personal terminal in its browser."""
if not ENABLE_TOOL_SERVERS:
async def get_terminal_json(
request, user, metadata: dict, path: str, extra_params: dict | None = None, *, file: dict | None = None
):
"""Read JSON or upload a file through an admin terminal or a personal terminal's browser."""
metadata = metadata or {}
terminal_id = metadata.get('terminal_id')
if not ENABLE_TOOL_SERVERS or not terminal_id:
return None
import aiohttp
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
from open_webui.models.config import Config
from open_webui.models.groups import Groups
from open_webui.models.users import UserModel
from open_webui.utils.access_control import has_connection_access
from open_webui.utils.tools import build_tool_server_headers
metadata = metadata or {}
terminal_id = metadata.get('terminal_id')
if not terminal_id:
return None
user_model = user if isinstance(user, UserModel) else UserModel(**user)
connections = await Config.get('terminal_server.connections', []) or []
connection = next((item for item in connections if item.get('id') == terminal_id), None)
@ -179,29 +179,92 @@ async def get_terminal_json(request, user, metadata: dict, path: str, extra_para
headers['X-Session-Id'] = metadata['chat_id']
if context_id:
headers[TERMINAL_CONTEXT_HEADER] = context_id
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA)
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(
f'{get_terminal_server_url(connection)}{path}',
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
allow_redirects=False,
) as response:
return await response.json() if response.status == 200 else None
body = None
if file:
from open_webui.models.files import Files
from open_webui.storage.provider import Storage
source = await Files.get_file_by_id(file['id'])
if not source or source.user_id != user_model.id or not source.path:
return None
local_path = await asyncio.to_thread(Storage.get_file, source.path)
body = aiohttp.FormData()
body.add_field(
'file',
await asyncio.to_thread(Path(local_path).read_bytes),
filename=file['name'],
content_type=(source.meta or {}).get('content_type') or 'application/octet-stream',
)
headers = {key: value for key, value in headers.items() if key.lower() != 'content-type'}
timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TERMINAL)
try:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.request(
'POST' if file else 'GET',
f'{get_terminal_server_url(connection)}{path}',
headers=headers,
cookies=cookies,
data=body,
ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
allow_redirects=False,
) as response:
return await response.json() if 200 <= response.status < 300 else None
except TimeoutError as error:
raise TimeoutError(
f"Terminal '{connection.get('name') or terminal_id}' timed out. "
'It may still be starting. Please try again.'
) from error
event_caller = (extra_params or {}).get('__event_call__')
if event_caller:
result = await event_caller(
{
'type': 'request:terminal',
'data': {'terminal_id': terminal_id, 'path': path, 'session_id': metadata.get('session_id')},
'data': {
'terminal_id': terminal_id,
'path': path,
'session_id': metadata.get('session_id'),
**({'file': file} if file else {}),
},
}
)
return result.get('data') if isinstance(result, dict) else None
return None
async def upload_files_to_terminal(request, user, files: list[dict], metadata: dict, extra_params: dict):
"""Copy Open WebUI files to the selected terminal, adding terminal_path or terminal_error to each entry."""
if not metadata.get('terminal_id') or not files:
return
try:
cwd = await asyncio.wait_for(
get_terminal_json(request, user, metadata, '/files/cwd', extra_params),
timeout=AIOHTTP_CLIENT_TIMEOUT_TERMINAL,
)
directory = cwd.get('cwd') if isinstance(cwd, dict) else None
if not directory:
raise ValueError('Terminal working directory unavailable')
path = f'/files/upload?directory={quote(directory, safe="")}'
for entry in files:
try:
# The file ID keeps separate files from overwriting one another.
file = {'id': entry['id'], 'name': f'{entry["id"]}{Path(entry["name"]).suffix}'}
result = await asyncio.wait_for(
get_terminal_json(request, user, metadata, path, extra_params, file=file),
timeout=AIOHTTP_CLIENT_TIMEOUT_TERMINAL,
)
if not isinstance(result, dict) or not isinstance(result.get('path'), str) or not result['path']:
raise ValueError('Terminal upload failed')
entry['terminal_path'] = result['path']
except Exception as error:
log.warning('File terminal upload failed (%s)', type(error).__name__)
entry['terminal_error'] = 'File saved in Open WebUI, but upload to the selected terminal failed.'
except Exception as error:
log.warning('File terminal unavailable (%s)', type(error).__name__)
for entry in files:
entry['terminal_error'] = 'File saved in Open WebUI, but the selected terminal is unavailable.'
async def get_terminal_agents_md(request, user, metadata: dict, extra_params: dict | None = None) -> str | None:
"""Load the selected terminal user's home AGENTS.md afresh for this turn."""
try:

View file

@ -56,6 +56,7 @@ from open_webui.tools.builtin import (
delete_automation,
delete_calendar_event,
delete_memory,
display_file,
edit_image,
execute_code,
fetch_url,
@ -257,7 +258,6 @@ async def connect_mcp_server(
tool_specs = [spec for spec in tool_specs if is_string_allowed(spec['name'], function_name_filter_list)]
return client, tool_specs
except BaseException:
# MCP sessions must be closed in the same task that opened them.
await client.disconnect()
raise
@ -716,7 +716,7 @@ async def get_builtin_tools(
and has_chat_files
and await has_user_chat_permission('file_upload')
):
builtin_functions.extend([list_chat_files, query_chat_files, grep_chat_files, view_file])
builtin_functions.extend([list_chat_files, query_chat_files, grep_chat_files, view_file, display_file])
# Knowledge base tools - conditional injection based on model knowledge
# If model has attached knowledge (any type), only provide query_knowledge_files
@ -728,6 +728,7 @@ async def get_builtin_tools(
if ENABLE_KB_EXEC:
builtin_functions.append(kb_exec)
builtin_functions.append(query_knowledge_files)
builtin_functions.append(display_file)
# Notes attached to the model need view_note since kb_exec is file-only
if model_knowledge:
knowledge_types = {item.get('type') for item in model_knowledge}
@ -743,7 +744,7 @@ async def get_builtin_tools(
knowledge_types = {item.get('type') for item in model_knowledge}
if 'file' in knowledge_types or 'collection' in knowledge_types:
builtin_functions.extend([view_file, view_knowledge_file])
builtin_functions.extend([view_file, view_knowledge_file, display_file])
if 'note' in knowledge_types:
builtin_functions.append(view_note)
else:
@ -756,6 +757,7 @@ async def get_builtin_tools(
search_knowledge_files,
query_knowledge_files,
view_knowledge_file,
display_file,
]
)
@ -1076,32 +1078,6 @@ def clean_openai_tool_schema(spec: dict) -> dict:
return cleaned_spec
def add_terminal_display_file_inline_param(spec: dict) -> dict:
spec = copy.deepcopy(spec)
if spec.get('name') != 'display_file':
return spec
spec['description'] = (
f'{spec.get("description", "")} '
'Set inline=true when the file should be shown inline in the chat message instead of opening the file viewer. '
'Set page for PDF, DOCX, and PPTX files when you want the preview to open at a specific 1-based page or slide. '
'After display_file succeeds, do not display the same file again or emit Markdown for it.'
).strip()
parameters = spec.setdefault('parameters', {'type': 'object', 'properties': {}, 'required': []})
parameters.setdefault('type', 'object')
properties = parameters.setdefault('properties', {})
properties['inline'] = {
'type': 'boolean',
'description': 'Show the file inline in the chat message instead of opening the file viewer.',
}
properties['page'] = {
'type': 'integer',
'minimum': 1,
'description': 'For PDF, DOCX, and PPTX files, open the preview at this 1-based page or slide number.',
}
return spec
@cache
def get_builtin_function_introspection(func: Callable):
try:
@ -1588,7 +1564,9 @@ async def get_terminal_tools(
tools_dict = {}
for spec in specs:
function_name = spec['name']
tool_spec = clean_openai_tool_schema(add_terminal_display_file_inline_param(spec))
tool_spec = clean_openai_tool_schema(
get_builtin_tool_spec(display_file) if function_name == 'display_file' else spec
)
if function_name == 'run_command' and terminal_cwd:
tool_spec['description'] = (
@ -1749,9 +1727,7 @@ async def get_tool_servers_data(servers: list[dict[str, Any]]) -> list[dict[str,
response = {
'openapi': response,
'info': response.get('info', {}),
'specs': [
add_terminal_display_file_inline_param(spec) for spec in convert_openapi_to_tool_payload(response)
],
'specs': convert_openapi_to_tool_payload(response),
}
openapi_data = response.get('openapi', {})

View file

@ -7,7 +7,7 @@ from open_webui.env import (
AIOHTTP_CLIENT_SESSION_SSL,
VERSION,
)
from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
from open_webui.retrieval.web.utils import get_ssrf_safe_session, is_identity_encoding, validate_url
from open_webui.utils.json_codec import JSONCodec
log = logging.getLogger(__name__)
@ -90,8 +90,10 @@ async def post_webhook(name: str, url: str, message: str, event_data: dict, desc
json=payload,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
headers={'Accept-Encoding': 'identity'},
) as r:
r_text = await r.text()
# The reply is only logged, so a compressed one is left unread rather than failing the delivery.
r_text = await r.text() if is_identity_encoding(r.headers.getall('Content-Encoding', ())) else ''
r.raise_for_status()
log.debug('r.text: %s', r_text)

12
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "open-webui",
"version": "0.12.4",
"version": "0.12.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "open-webui",
"version": "0.12.4",
"version": "0.12.5",
"dependencies": {
"@azure/msal-browser": "^4.5.0",
"@codemirror/lang-javascript": "^6.2.2",
@ -94,7 +94,7 @@
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
"prosemirror-view": "1.42.6",
"pyodide": "^314.0.3",
"shiki": "^4.0.1",
"socket.io-client": "^4.8.3",
@ -12453,9 +12453,9 @@
}
},
"node_modules/prosemirror-view": {
"version": "1.42.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz",
"integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==",
"version": "1.42.6",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.12",

View file

@ -1,6 +1,6 @@
{
"name": "open-webui",
"version": "0.12.4",
"version": "0.12.5",
"private": true,
"scripts": {
"crypto:verify": "node scripts/encryption/artifact.mjs",
@ -145,7 +145,7 @@
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.7.1",
"prosemirror-view": "^1.34.3",
"prosemirror-view": "1.42.6",
"pyodide": "^314.0.3",
"shiki": "^4.0.1",
"socket.io-client": "^4.8.3",

View file

@ -757,10 +757,6 @@ html.high-contrast.dark .ProseMirror p.is-editor-empty:first-child::before {
@apply bg-transparent p-0 m-0 text-inherit;
}
/* this is a rough fix for the first cursor position when the first paragraph is empty */
.ProseMirror > .ProseMirror-yjs-cursor:first-child {
margin-top: 1rem;
}
/* This gives the remote user caret. The colors are automatically overwritten*/
.ProseMirror-yjs-cursor {
position: relative;
@ -773,17 +769,20 @@ html.high-contrast.dark .ProseMirror p.is-editor-empty:first-child::before {
pointer-events: none;
}
/* This renders the username above the caret */
.ProseMirror-yjs-cursor > div {
position: absolute;
top: -1.05em;
left: -1px;
.ProseMirror-yjs-cursor-label {
font-size: 0.8125rem;
line-height: 1.25;
background-color: rgb(250, 129, 0);
user-select: none;
color: white;
padding-left: 0.125rem;
padding-right: 0.125rem;
white-space: nowrap;
pointer-events: none;
}
.ProseMirror-yjs-cursor-label[data-popper-reference-hidden] {
opacity: 0;
}
body {

View file

@ -254,7 +254,8 @@ export const getModelChats = async (
skip: number = 0,
limit: number = 50,
orderBy: string | null = null,
direction: string | null = null
direction: string | null = null,
groupId: string | null = null
) => {
let error = null;
@ -265,6 +266,7 @@ export const getModelChats = async (
if (limit) searchParams.append('limit', limit.toString());
if (orderBy) searchParams.append('order_by', orderBy);
if (direction) searchParams.append('direction', direction);
if (groupId) searchParams.append('group_id', groupId);
const res = await fetch(
`${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/chats?${searchParams.toString()}`,

View file

@ -0,0 +1,201 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type SessionScope = {
startDate: number | null;
endDate: number | null;
groupId: string | null;
};
export type Session = {
id: string;
title: string;
user_id: string | null;
user_name: string | null;
folder_name: string | null;
kind: string | null;
forked_from?: string | null;
messages: number;
responses: number;
prompts: number;
recorded_responses: number;
complete_responses: number;
tokens: number;
cache_read?: number | null;
cache_write?: number | null;
cache_read_responses?: number;
cache_write_responses?: number;
errors: number;
started_at: number | null;
last_active: number | null;
span_seconds: number | null;
model_id: string | null;
model_count: number;
};
export type SessionMessage = {
id: string;
role: string;
model_id: string | null;
parent_id: string | null;
created_at: number;
done: boolean;
preview: string | null;
preview_truncated: boolean | null;
error: string | null;
tokens: number | null;
output_count: number;
};
export type SessionEvent = {
index: number;
type: string | null;
name: string | null;
call_id: string | null;
status: string | null;
error_type?: string | null;
started_at: number | null;
ended_at: number | null;
duration_seconds: number | null;
details: string | null;
truncated: boolean | null;
};
export const SESSION_TOKEN_RANGES = [
{ min: 0, max: 1, label: '0' },
{ min: 1, max: 1000, label: '<1K' },
{ min: 1000, max: 10000, label: '1K–<10K' },
{ min: 10000, max: 100000, label: '10K–<100K' },
{ min: 100000, max: 1000000, label: '100K–<1M' },
{ min: 1000000, max: null, label: '≥1M' }
];
export type SessionOverview = {
token_distribution: number[];
unrecorded_sessions: number;
max_tokens: number;
sessions: number;
tokens: number;
prompts: number;
responses: number;
errors: number;
recorded_responses: number;
complete_responses: number;
start: number | null;
end: number | null;
};
export type SessionOrder =
| 'title'
| 'user'
| 'recent'
| 'tokens'
| 'responses'
| 'errors'
| 'duration';
export type Page<T> = { items: T[]; has_more: boolean };
async function request<T>(
token: string,
path: string,
scope: SessionScope,
params: Record<string, string | number | boolean>,
signal: AbortSignal
): Promise<T> {
const query = new URLSearchParams();
if (scope.startDate !== null) query.set('start_date', String(scope.startDate));
if (scope.endDate !== null) query.set('end_date', String(scope.endDate));
if (scope.groupId) query.set('group_id', scope.groupId);
for (const [key, value] of Object.entries(params))
if (value !== '') query.set(key, String(value));
const response = await fetch(`${WEBUI_API_BASE_URL}/analytics/sessions${path}?${query}`, {
headers: { authorization: `Bearer ${token}` },
signal
});
if (!response.ok) throw new Error(`Unable to load session analytics (${response.status})`);
return response.json();
}
export const getSessions = (
token: string,
scope: SessionScope,
params: {
query: string;
model_id: string;
hide_subagents: boolean;
order_by: SessionOrder;
direction: 'asc' | 'desc';
include_summary: boolean;
token_min?: number;
token_max?: number;
skip: number;
limit: number;
},
signal: AbortSignal
) => request<Page<Session> & { overview?: SessionOverview }>(token, '', scope, params, signal);
export const getSessionMessages = (
token: string,
id: string,
scope: SessionScope,
skip: number,
signal: AbortSignal
) =>
request<Page<SessionMessage> & { session: Session }>(
token,
`/${encodeURIComponent(id)}`,
scope,
{ skip, limit: 25 },
signal
);
export const getSessionEvents = (
token: string,
id: string,
messageId: string,
scope: SessionScope,
skip: number,
signal: AbortSignal,
limit = 20
) =>
request<Page<SessionEvent>>(
token,
`/${encodeURIComponent(id)}/messages/${encodeURIComponent(messageId)}/events`,
scope,
{ skip, limit },
signal
);
export type TraceItem = Omit<SessionEvent, 'details' | 'truncated'> & {
message_id: string;
result_index: number | null;
created_at: number;
model_id: string | null;
};
export type UsageBar = {
first: number;
last: number;
offset: number;
input_tokens: number | null;
output_tokens: number | null;
recorded: number;
complete: number;
errors: number;
model_id: string | null;
};
export type SessionChart = {
timed_operations: number;
duration_operations: number;
usage: UsageBar[];
start: number | null;
end: number | null;
max_duration: number | null;
};
export type TraceView = 'all' | 'tools' | 'errors';
export const getSessionTrace = (
token: string,
id: string,
scope: SessionScope,
skip: number,
view: TraceView,
signal: AbortSignal,
includeChart = false
) =>
request<Page<TraceItem> & { session: Session; chart?: SessionChart }>(
token,
`/${encodeURIComponent(id)}/trace`,
scope,
{ skip, limit: 50, view, include_chart: includeChart },
signal
);

View file

@ -481,6 +481,18 @@ export const getAllChats = async (token: string) => {
return chats;
};
export const getChatSearchSnippet = async (token: string, id: string, text: string) => {
const searchParams = new URLSearchParams({ text });
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/search/snippet?${searchParams}`, {
headers: {
Accept: 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
});
if (!res.ok) throw getErrorDetail(await res.json());
return (await res.json()) as string | null;
};
export const getChatListBySearchText = async (token: string, text: string, page: number = 1) => {
let error = null;
@ -753,10 +765,11 @@ export const getChatListByTagName = async (token: string = '', tagName: string)
}));
};
export const getChatById = async (token: string, id: string) => {
export const getChatById = async (token: string, id: string, signal?: AbortSignal) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
signal,
method: 'GET',
headers: {
Accept: 'application/json',
@ -772,6 +785,7 @@ export const getChatById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
if (signal?.aborted) return null;
error = err.detail;
console.error(err);
@ -1465,10 +1479,11 @@ export const deleteChatById = async (token: string, id: string) => {
return res;
};
export const getTagsById = async (token: string, id: string) => {
export const getTagsById = async (token: string, id: string, signal?: AbortSignal) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/tags`, {
signal,
method: 'GET',
headers: {
Accept: 'application/json',
@ -1484,6 +1499,7 @@ export const getTagsById = async (token: string, id: string) => {
return json;
})
.catch((err) => {
if (signal?.aborted) return null;
error = getErrorDetail(err);
console.error(err);

View file

@ -361,10 +361,11 @@ export const stopTasksByChatId = async (token: string, chat_id: string) => {
return res;
};
export const getTaskIdsByChatId = async (token: string, chat_id: string) => {
export const getTaskIdsByChatId = async (token: string, chat_id: string, signal?: AbortSignal) => {
let error = null;
const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/chat/${encodeURIComponent(chat_id)}`, {
signal,
method: 'GET',
headers: {
Accept: 'application/json',
@ -377,6 +378,7 @@ export const getTaskIdsByChatId = async (token: string, chat_id: string) => {
return res.json();
})
.catch((err) => {
if (signal?.aborted) return null;
console.error(err);
if ('detail' in err) {
error = err.detail;

View file

@ -8,6 +8,39 @@ type NoteItem = {
access_grants?: object[];
};
export type NoteContent = { json: any; html: string; md: string };
export type NotePage = {
id: string;
parent_id?: string | null;
inherit_note_title?: boolean;
title: string;
content: NoteContent;
versions: NoteContent[];
revision: number;
generation: number;
};
export type NotePageOperation = {
action: 'create' | 'rename' | 'duplicate' | 'reorder' | 'move' | 'delete';
revision: number;
page_id?: string;
new_id?: string;
parent_id?: string | null;
before_id?: string | null;
title?: string;
order?: string[];
};
export const updateNotePages = async (token: string, id: string, operation: NotePageOperation) => {
const response = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/pages/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify(operation)
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'Failed to update pages');
return result;
};
export const createNewNote = async (token: string, note: NoteItem) => {
let error = null;
@ -318,7 +351,7 @@ export const createNoteChatById = async (token: string, id: string) => {
export const updateNoteById = async (
token: string,
id: string,
note: Omit<NoteItem, 'access_grants'>
note: Omit<NoteItem, 'access_grants'> & { page_id?: string }
) => {
let error = null;

View file

@ -15,7 +15,7 @@ export const getErrorMessage = (err: any, fallback = 'Server connection failed')
err?.message,
err?.content,
err
].find((value) => typeof value === 'string') ?? fallback
].find((value) => typeof value === 'string' && value.trim()) ?? fallback
);
};

View file

@ -203,6 +203,8 @@ type EmbeddingModelUpdateForm = {
azure_openai_config?: AzureOpenAIConfigForm;
RAG_EMBEDDING_ENGINE: string;
RAG_EMBEDDING_MODEL: string;
RAG_EMBEDDING_QUERY_PREFIX?: string;
RAG_EMBEDDING_CONTENT_PREFIX?: string;
RAG_EMBEDDING_BATCH_SIZE?: number;
ENABLE_ASYNC_EMBEDDING?: boolean;
RAG_EMBEDDING_CONCURRENT_REQUESTS?: number;

View file

@ -757,3 +757,23 @@ export const revokeUserSessions = async (token: string, userId: string) => {
if (!response.ok) throw new Error(result.detail || 'Failed to revoke sessions');
return result;
};
export const rotateUserAPIKeyById = async (token: string, userId: string): Promise<string> => {
const response = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/api_key/rotate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'Failed to rotate API key');
return result.api_key;
};
export const deleteUserAPIKeyById = async (token: string, userId: string) => {
const response = await fetch(`${WEBUI_API_BASE_URL}/users/${userId}/api_key`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
const result = await response.json();
if (!response.ok) throw new Error(result.detail || 'Failed to delete API key');
return result;
};

View file

@ -460,6 +460,8 @@
enable = true;
functionNameFilterList = '';
accessGrants = [];
headers = '';
showAdvanced = false;
};
const init = async () => {

View file

@ -12,6 +12,7 @@
export let model: { id: string; name: string } | null = null;
export let startDate: number | null = null;
export let endDate: number | null = null;
export let groupId: string | null = null;
export let onClose: () => void = () => {};
const i18n: any = getContext('i18n');
@ -95,7 +96,8 @@
0,
PAGE_SIZE,
chatOrderBy,
chatDirection
chatDirection,
groupId
);
const chats = res?.chats ?? [];
chatList = chats.map((c: any) => ({
@ -127,7 +129,8 @@
skip,
PAGE_SIZE,
chatOrderBy,
chatDirection
chatDirection,
groupId
);
const chats = res?.chats ?? [];
const newChats = chats.map((c: any) => ({

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,433 @@
<script lang="ts">
import { getContext, onMount, onDestroy, tick } from 'svelte';
import dayjs from 'dayjs';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import SessionUsageChart from './SessionUsageChart.svelte';
import SessionWaterfall from './SessionWaterfall.svelte';
import { modelColor } from './UsageChart.svelte';
import {
getSessionTrace,
type Session,
type SessionScope,
type SessionChart,
type TraceItem,
type TraceView
} from '$lib/apis/analytics/sessions';
export let session: Session;
export let scope: SessionScope;
export let modelNames: Record<string, string>;
export let onClose: () => void;
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
let items: TraceItem[] = [];
let expanded = new Set<string>();
type Row = { item: TraceItem; offset: number; expandable?: boolean; failed?: boolean };
$: groups = groupItems(items, page);
$: rows =
view === 'all'
? groups.flatMap((group) => [
group.header,
...(expanded.has(group.id) ? group.operations : [])
])
: items.map((item, index): Row => ({ item, offset: page * 50 + index }));
function groupItems(items: TraceItem[], page: number) {
const grouped = new Map<string, { id: string; header: Row; operations: Row[] }>();
items.forEach((item, index) => {
let group = grouped.get(item.message_id);
if (!group) {
group = {
id: item.message_id,
header: {
item:
item.index < 0
? item
: {
...item,
index: -1,
type: 'response',
status: null,
started_at: null,
ended_at: null,
duration_seconds: null
},
offset: page * 50 + index,
failed: false
},
operations: []
};
grouped.set(item.message_id, group);
}
group.header.failed ||= item.status === 'failed';
if (item.index >= 0) group.operations.push({ item, offset: page * 50 + index });
group.header.expandable = group.operations.length > 0;
});
return [...grouped.values()];
}
function toggleGroup(id: string) {
const next = new Set(expanded);
if (next.has(id)) next.delete(id);
else next.add(id);
expanded = next;
}
let view: TraceView = 'all';
let visualization: 'tokens' | 'timeline' = 'tokens';
let chart: SessionChart | null = null;
let targetOffset: number | null = null;
let page = 0;
let hasMore = false;
let loading = true;
let error = false;
let mounted = false;
let controller: AbortController;
let content: HTMLElement;
$: formatter = new Intl.NumberFormat($i18n.language, {
notation: 'compact',
maximumFractionDigits: 1
});
$: if (mounted && view && page >= 0) load();
async function load() {
controller?.abort();
expanded = new Set();
const current = new AbortController();
controller = current;
loading = true;
error = false;
try {
const result = await getSessionTrace(
localStorage.token,
session.id,
scope,
page * 50,
view,
current.signal,
chart === null
);
if (!current.signal.aborted) {
items = result.items;
hasMore = result.has_more;
session = result.session;
if (result.chart) chart = result.chart;
}
} catch {
if (!current.signal.aborted) error = true;
} finally {
if (!current.signal.aborted) {
loading = false;
await tick();
if (targetOffset !== null && !error) revealResponse();
else if (content) content.scrollTop = 0;
}
}
}
async function revealResponse() {
const group = groups.find((group) => group.header.offset === targetOffset);
if (group) expanded = new Set([...expanded, group.id]);
await tick();
const row = content?.querySelector<HTMLElement>(`[data-trace-offset="${targetOffset}"]`);
row?.scrollIntoView({ block: 'nearest' });
row?.focus({ preventScroll: true });
}
async function jumpToResponse(offset: number) {
targetOffset = offset;
view = 'all';
page = Math.floor(offset / 50);
await tick();
if (!loading) revealResponse();
}
function filterTrace(next: TraceView) {
targetOffset = null;
view = next;
page = 0;
}
const label = (item: TraceItem) =>
item.type === 'response'
? modelNames[item.model_id || ''] || item.model_id || $i18n.t('Response')
: item.name ||
{
function_call: $i18n.t('Tool call'),
function_call_output: $i18n.t('Tool result'),
reasoning: $i18n.t('Reasoning'),
'open_webui:code_interpreter': $i18n.t('Code interpreter')
}[item.type || ''] ||
item.type ||
$i18n.t('Saved item');
const status = (item: TraceItem) =>
({
completed: $i18n.t('Completed'),
failed: $i18n.t('Failed'),
in_progress: $i18n.t('In progress'),
pending: $i18n.t('Pending'),
cancelled: $i18n.t('Cancelled'),
rejected: $i18n.t('Rejected')
})[item.status || ''] || '';
const duration = (seconds: number) =>
seconds < 1
? `${Math.round(seconds * 1000)}ms`
: seconds < 60
? `${seconds.toFixed(1)}s`
: `${(seconds / 60).toFixed(1)}m`;
onMount(() => {
mounted = true;
});
onDestroy(() => {
controller?.abort();
});
</script>
<section
aria-label={$i18n.t('Session details')}
class="flex max-h-[calc(100dvh-6rem)] min-w-0 flex-col text-xs font-normal text-gray-500 dark:text-gray-400"
>
<div class="flex shrink-0 items-start gap-3 mb-3">
<h3 class="min-w-0 flex-1 text-sm font-normal text-gray-900 dark:text-gray-100 line-clamp-2">
{session.title || $i18n.t('Untitled chat')}
</h3>
<a
href={`/c/${encodeURIComponent(session.id)}`}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 py-1 hover:text-gray-900 dark:hover:text-gray-100">{$i18n.t('Open chat')} ↗</a
>
<button
type="button"
data-close-session
aria-label={$i18n.t('Close session details')}
on:click={onClose}
class="shrink-0 rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800"
><XMark className="size-4" /></button
>
</div>
<div
bind:this={content}
class="min-h-0 overflow-y-auto overscroll-contain scrollbar-hover"
data-session-content
>
<div class="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[0.6875rem]">
{#if session.user_id}
<ProfilePreview
user={{ id: session.user_id, name: session.user_name }}
side="bottom"
align="start"
sideOffset={6}
className="gap-1.75"
>
<img
src={`${WEBUI_API_BASE_URL}/users/${session.user_id}/profile/image`}
alt=""
class="size-4 rounded-full object-cover shrink-0"
loading="lazy"
on:error={(event) => {
const image = event.currentTarget as HTMLImageElement;
if (!image.src.endsWith('/user.png')) image.src = '/user.png';
}}
/>
<span class="block max-w-28 truncate">{session.user_name || $i18n.t('User')}</span>
</ProfilePreview>
{:else}<span>{session.user_name || '-'}</span>{/if}
{#if session.started_at}<span
>{dayjs
.unix(session.started_at)
.locale($i18n.language)
.format('MMM D, YYYY · HH:mm')}</span
>{/if}
{#if session.kind === 'subagent'}<span>{$i18n.t('Sub-agent')}</span>{/if}
</div>
<dl class="flex flex-wrap items-baseline gap-x-4 gap-y-1 mb-3">
<div class="flex gap-1.5">
<dt>{$i18n.t('Prompts')}</dt>
<dd class="tabular-nums text-gray-900 dark:text-gray-200">{session.prompts}</dd>
</div>
<div class="flex gap-1.5">
<dt>{$i18n.t('Responses')}</dt>
<dd class="tabular-nums text-gray-900 dark:text-gray-200">{session.responses}</dd>
</div>
{#if session.recorded_responses}<div
class="flex gap-1.5"
title={`${session.recorded_responses}/${session.responses} ${$i18n.t('responses with recorded usage')}`}
>
<dt>{$i18n.t('Tokens')}</dt>
<dd class="tabular-nums text-gray-900 dark:text-gray-200">
{formatter.format(session.tokens)}{session.complete_responses < session.responses
? '*'
: ''}
</dd>
</div>{/if}
{#if session.cache_read != null}<div class="flex gap-1.5">
<dt>{$i18n.t('Cache read')}</dt>
<dd class="tabular-nums text-gray-900 dark:text-gray-200">
{formatter.format(session.cache_read)}
</dd>
</div>{/if}
{#if session.cache_write != null}<div class="flex gap-1.5">
<dt>{$i18n.t('Cache write')}</dt>
<dd class="tabular-nums text-gray-900 dark:text-gray-200">
{formatter.format(session.cache_write)}
</dd>
</div>{/if}
</dl>
<div
class="flex items-center gap-3 mb-2"
role="group"
aria-label={$i18n.t('Session visualization')}
>
{#each [{ id: 'tokens', label: $i18n.t('Tokens') }, { id: 'timeline', label: $i18n.t('Timeline') }] as option}
<button
type="button"
aria-pressed={visualization === option.id}
class="py-1 hover:text-gray-900 dark:hover:text-gray-100"
class:text-gray-900={visualization === option.id}
class:dark:text-gray-100={visualization === option.id}
on:click={() => (visualization = option.id as typeof visualization)}
>{option.label}</button
>
{/each}
</div>
{#if visualization === 'tokens'}
{#if chart?.usage.some((bar) => bar.recorded > 0)}
<SessionUsageChart usage={chart.usage} onSelect={jumpToResponse} />
{:else if !loading && !error}
<p class="mb-4 text-[0.6875rem] text-gray-400 dark:text-gray-500">
{$i18n.t('Token usage not recorded')}
</p>
{/if}
{:else if !loading && !error}
<SessionWaterfall {items} {page} {label} {status} />
{/if}
<div class="flex items-center gap-3 mb-2" aria-label={$i18n.t('Trace filter')}>
{#each [{ id: 'all', label: $i18n.t('All') }, { id: 'tools', label: $i18n.t('Tools') }, { id: 'errors', label: $i18n.t('Errors') }] as filter}
<button
type="button"
aria-pressed={view === filter.id}
class="py-1 hover:text-gray-900 dark:hover:text-gray-100"
class:text-gray-900={view === filter.id}
class:dark:text-gray-100={view === filter.id}
on:click={() => filterTrace(filter.id as TraceView)}>{filter.label}</button
>
{/each}
</div>
<div aria-busy={loading} aria-label={$i18n.t('Saved execution sequence')}>
{#if loading}<div
class="flex justify-center py-12"
role="status"
aria-label={$i18n.t('Loading trace')}
>
<Spinner className="size-5" />
</div>
{:else if error}<div role="alert" class="py-12 text-center">
{$i18n.t('Unable to load session.')}
<button type="button" class="underline ml-2" on:click={load}>{$i18n.t('Retry')}</button>
</div>
{:else}
{#each rows as row (`${row.item.message_id}:${row.item.index}`)}
{@const item = row.item}
{#if item.index < 0}
<button
type="button"
data-model-group
aria-expanded={view === 'all' && row.expandable
? expanded.has(item.message_id)
: undefined}
aria-disabled={view !== 'all' || !row.expandable}
on:click={() => {
if (view === 'all' && row.expandable) toggleGroup(item.message_id);
}}
class="block w-full text-left mt-2 py-1.5 px-2.5 hover:text-gray-900 dark:hover:text-gray-100 focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
data-trace-offset={row.offset}
class:bg-gray-50={view === 'all' && targetOffset === row.offset}
class:dark:bg-gray-850={view === 'all' && targetOffset === row.offset}
>
<div class="flex items-center gap-2">
<span
class="size-2 shrink-0"
style:background={item.model_id
? modelColor(item.model_id)
: 'var(--color-gray-400)'}
></span><span
class="min-w-0 truncate text-gray-900 dark:text-gray-200"
title={label(item)}>{label(item)}</span
>{#if row.failed || item.status === 'failed'}<span>{$i18n.t('Errors')}</span
>{/if}{#if item.duration_seconds !== null}<span
data-response-duration
class="shrink-0 text-[0.6875rem] tabular-nums"
title={`${$i18n.t('Response duration')} · ${dayjs.unix(item.started_at!).format('MMM D, HH:mm:ss')}–${dayjs.unix(item.ended_at!).format('MMM D, HH:mm:ss')}`}
>{duration(item.duration_seconds)}</span
>{/if}{#if item.created_at}<span
class="ml-auto shrink-0 text-[0.6875rem] tabular-nums"
>{dayjs.unix(item.created_at).format('HH:mm')}</span
>{/if}
{#if view === 'all' && row.expandable}<ChevronRight
className={`size-3 shrink-0 ${expanded.has(item.message_id) ? 'rotate-90' : ''}`}
/>{/if}
</div>
</button>
{:else}
<div class:ps-4={view === 'all'}>
<div
data-operation
title={item.error_type || undefined}
aria-label={[
label(item),
status(item),
item.duration_seconds !== null ? duration(item.duration_seconds) : ''
]
.filter(Boolean)
.join(' · ')}
class="flex items-center gap-2 w-full text-left px-2.5 py-1.5"
>
<span
class="size-2 shrink-0"
style:background={item.model_id
? modelColor(item.model_id)
: 'var(--color-gray-400)'}
></span>
<span
class="min-w-0 flex-1 truncate text-gray-900 dark:text-gray-200"
title={label(item)}>{label(item)}</span
>
{#if status(item)}<span class="text-[0.6875rem] shrink-0">{status(item)}</span>{/if}
{#if item.duration_seconds !== null}<span
class="text-[0.6875rem] tabular-nums shrink-0 min-w-12 text-right"
>{duration(item.duration_seconds)}</span
>{/if}
</div>
</div>
{/if}
{:else}<p class="py-12 text-center">
{$i18n.t('No saved items match this filter.')}
</p>{/each}
{/if}
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 pt-3 text-[0.6875rem]">
<button
type="button"
aria-label={$i18n.t('Previous trace items')}
disabled={loading || page === 0}
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 disabled:opacity-30"
on:click={() => {
targetOffset = null;
page--;
}}><ChevronLeft className="size-3.5" /></button
>
<span>{$i18n.t('Page')} {page + 1}</span>
<button
type="button"
aria-label={$i18n.t('Next trace items')}
disabled={loading || error || !hasMore}
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 disabled:opacity-30"
on:click={() => {
targetOffset = null;
page++;
}}><ChevronRight className="size-3.5" /></button
>
</div>
</section>

View file

@ -0,0 +1,437 @@
<script lang="ts">
import { getContext, onMount, onDestroy, tick } from 'svelte';
import dayjs from 'dayjs';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Check from '$lib/components/icons/Check.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import {
getSessions,
SESSION_TOKEN_RANGES,
type Session,
type SessionOrder,
type SessionScope,
type SessionOverview
} from '$lib/apis/analytics/sessions';
import SessionDetail from './SessionDetail.svelte';
import { modelColor } from './UsageChart.svelte';
import SessionSignals from './SessionSignals.svelte';
export let scope: SessionScope;
export let modelNames: Record<string, string> = {};
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
let query = '';
let modelId = '';
let hideSubagents = false;
let order: SessionOrder = 'tokens';
let direction: 'asc' | 'desc' = 'desc';
let sortFocus: SessionOrder | null = null;
let explorer: HTMLElement;
$: columns = [
{ key: 'title', label: $i18n.t('Session') },
{ key: 'user', label: $i18n.t('User') },
{ key: 'recent', label: $i18n.t('Last active') },
{ key: 'responses', label: $i18n.t('Responses'), numeric: true },
{ key: 'tokens', label: $i18n.t('Tokens'), numeric: true },
{ key: 'errors', label: $i18n.t('Response errors'), numeric: true },
{ key: 'duration', label: $i18n.t('Span'), numeric: true }
] as { key: SessionOrder; label: string; numeric?: boolean }[];
function toggleSort(key: SessionOrder) {
direction =
order === key
? direction === 'asc'
? 'desc'
: 'asc'
: key === 'title' || key === 'user'
? 'asc'
: 'desc';
order = key;
sortFocus = key;
}
let tokenRange: number | null = null;
let page = 0;
let items: Session[] = [];
let overview: SessionOverview | null = null;
let overviewKey = '';
let hasMore = false;
let selected: Session | null = null;
let detailPanel: HTMLElement;
let selectedButton: HTMLElement | null = null;
async function selectSession(session: Session, event: MouseEvent) {
selectedButton = (event.currentTarget as HTMLTableRowElement).querySelector('button');
selected = session;
await tick();
detailPanel
?.querySelector<HTMLButtonElement>('[data-close-session]')
?.focus({ preventScroll: true });
}
async function closeSession() {
selected = null;
await tick();
selectedButton?.focus({ preventScroll: true });
}
let mounted = false;
let loading = true;
let error = false;
let controller: AbortController;
let timer: ReturnType<typeof setTimeout>;
const pageSize = 25;
$: formatter = new Intl.NumberFormat($i18n.language, {
notation: 'compact',
maximumFractionDigits: 1
});
$: scopeKey = JSON.stringify([scope, query, modelId, hideSubagents]);
$: if (scopeKey) tokenRange = null;
$: filterKey = JSON.stringify([scopeKey, order, direction, tokenRange]);
$: if (filterKey) {
page = 0;
selected = null;
}
$: if (mounted && filterKey && page >= 0) scheduleLoad();
function scheduleLoad() {
clearTimeout(timer);
controller?.abort();
loading = true;
error = false;
timer = setTimeout(load, 200);
}
async function load() {
controller?.abort();
const current = new AbortController();
controller = current;
loading = true;
error = false;
try {
const result = await getSessions(
localStorage.token,
scope,
{
...(tokenRange !== null
? {
token_min: SESSION_TOKEN_RANGES[tokenRange].min,
...(SESSION_TOKEN_RANGES[tokenRange].max !== null
? { token_max: SESSION_TOKEN_RANGES[tokenRange].max! }
: {})
}
: {}),
query,
model_id: modelId,
hide_subagents: hideSubagents,
order_by: order,
direction,
include_summary: overviewKey !== scopeKey,
skip: page * pageSize,
limit: pageSize
},
current.signal
);
if (!current.signal.aborted) {
items = result.items;
if (result.overview) {
overview = result.overview;
overviewKey = scopeKey;
}
hasMore = result.has_more;
}
} catch {
if (!current.signal.aborted) {
error = true;
items = [];
}
} finally {
if (!current.signal.aborted) {
loading = false;
await tick();
if (sortFocus && document.activeElement === document.body)
explorer
?.querySelector<HTMLButtonElement>(`[data-sort="${sortFocus}"]`)
?.focus({ preventScroll: true });
sortFocus = null;
}
}
}
const span = (seconds: number | null) =>
seconds === null
? $i18n.t('Not logged')
: seconds < 60
? `${Math.round(seconds)}s`
: seconds < 3600
? `${Math.round(seconds / 60)}m`
: `${(seconds / 3600).toFixed(1)}h`;
onMount(() => {
mounted = true;
});
onDestroy(() => {
clearTimeout(timer);
controller?.abort();
});
</script>
<div class="min-w-0">
<section
bind:this={explorer}
aria-label={$i18n.t('Sessions')}
class="min-w-0 flex-1 text-xs text-gray-500 dark:text-gray-400"
>
{#if overview && overviewKey === scopeKey && !error}<SessionSignals
{overview}
selectedRange={tokenRange}
onSelect={(index) => (tokenRange = index)}
/>{/if}
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
<input
type="search"
bind:value={query}
maxlength="200"
aria-label={$i18n.t('Search sessions')}
placeholder={$i18n.t('Search sessions, users or folders')}
class="min-w-0 h-7 w-full sm:w-56 bg-transparent p-0 text-xs text-gray-500 dark:text-gray-400 placeholder:text-gray-400 outline-none focus:text-gray-900 dark:focus:text-gray-100"
/>
<div class="flex w-full sm:w-auto items-center gap-x-4">
<select
bind:value={modelId}
aria-label={$i18n.t('Model')}
class="min-w-0 w-fit max-w-40 pr-5 py-1 text-xs bg-transparent outline-none text-gray-500 dark:text-gray-400"
>
<option value="">{$i18n.t('All models')}</option>
{#each Object.entries(modelNames).sort( (a, b) => a[1].localeCompare(b[1]) ) as [id, name]}<option
value={id}>{name}</option
>{/each}
</select>
<div class="ml-auto shrink-0">
<Dropdown align="end" closeOnSelect>
<button
type="button"
aria-label={$i18n.t('Session options')}
class="flex items-center justify-center p-1.5 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<EllipsisHorizontal className="size-4" />
</button>
<div slot="content">
<DropdownMenu className="min-w-48">
<button
type="button"
role="menuitemcheckbox"
aria-checked={hideSubagents}
on:click={() => (hideSubagents = !hideSubagents)}
>
<span class="flex-1 text-left">{$i18n.t('Hide sub-agents')}</span>
<span class="size-3.5 shrink-0"
>{#if hideSubagents}<Check className="size-3.5" />{/if}</span
>
</button>
</DropdownMenu>
</div>
</Dropdown>
</div>
</div>
</div>
<div aria-busy={loading} class="min-h-40">
{#if loading}
<div
class="flex justify-center py-12"
role="status"
aria-label={$i18n.t('Loading sessions')}
>
<Spinner className="size-5" />
</div>
{:else if error}
<div role="alert" class="py-12 text-center">
{$i18n.t('Unable to load sessions.')}
<button type="button" class="underline underline-offset-2 ml-2" on:click={load}
>{$i18n.t('Retry')}</button
>
</div>
{:else if !items.length}
<div class="py-12 text-center">{$i18n.t('No sessions match these filters.')}</div>
{:else}
<div class="max-h-[22rem] overflow-auto scrollbar-hover">
<table class="session-table w-full text-left text-xs tabular-nums whitespace-nowrap">
<thead class="sticky top-0 z-10 bg-white dark:bg-gray-900 text-[0.6875rem]">
<tr>
{#each columns as column}
<th
scope="col"
class="font-normal"
aria-sort={order === column.key
? direction === 'asc'
? 'ascending'
: 'descending'
: 'none'}
title={column.key === 'duration'
? $i18n.t('First to last saved message, including idle time')
: undefined}
>
<button
type="button"
data-sort={column.key}
on:click={() => toggleSort(column.key)}
class="w-full flex gap-1.5 items-center px-2.5 py-1.5 text-left font-normal cursor-pointer select-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
class:justify-end={column.numeric}
>
{column.label}
{#if order === column.key}
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
className="size-2"
/>{/if}
{:else}<span class="invisible"><ChevronUp className="size-2" /></span>{/if}
</button>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each items as session (session.id)}
<!-- The native title button provides keyboard activation; its click bubbles to the row. -->
<tr
on:click={(event) => selectSession(session, event)}
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
class:bg-gray-50={selected?.id === session.id}
class:dark:bg-gray-850={selected?.id === session.id}
>
<td class="px-2.5 py-1.5">
<button
type="button"
class="text-left block w-full max-w-[12rem] sm:max-w-80 rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
>
<span
class="block truncate text-gray-900 dark:text-gray-200"
title={session.title}>{session.title || $i18n.t('Untitled chat')}</span
>
<span
class="flex items-center gap-2 text-[0.6875rem] text-gray-400 dark:text-gray-500 mt-0.5"
><span class="truncate max-w-40"
>{session.folder_name ||
(session.model_id
? modelNames[session.model_id] || session.model_id
: $i18n.t('No model recorded'))}</span
>{#if session.forked_from}<span class="shrink-0">{$i18n.t('Fork')}</span
>{/if}{#if session.kind === 'subagent'}<span class="shrink-0"
>{$i18n.t('Sub-agent')}</span
>{/if}{#if !session.folder_name && session.model_count > 1}<span
class="shrink-0">+{session.model_count - 1}</span
>{/if}</span
>
</button>
</td>
<td class="px-2.5 py-1.5"
>{#if session.user_id}
<ProfilePreview
user={{ id: session.user_id, name: session.user_name }}
side="right"
align="center"
sideOffset={6}
className="gap-1.75"
>
<img
src={`${WEBUI_API_BASE_URL}/users/${session.user_id}/profile/image`}
alt=""
class="size-4 rounded-full object-cover shrink-0"
loading="lazy"
on:error={(event) => {
const image = event.currentTarget as HTMLImageElement;
if (!image.src.endsWith('/user.png')) image.src = '/user.png';
}}
/>
<span class="block max-w-28 truncate"
>{session.user_name || $i18n.t('User')}</span
>
</ProfilePreview>
{:else}<span>{session.user_name || '-'}</span>{/if}</td
>
<td
class="px-2.5 py-1.5"
title={session.last_active
? dayjs.unix(session.last_active).format('YYYY-MM-DD HH:mm:ss')
: ''}
>{session.last_active
? dayjs
.unix(session.last_active)
.locale($i18n.language)
.format('MMM D, HH:mm')
: $i18n.t('Not logged')}</td
>
<td class="px-2.5 py-1.5 text-right"
>{session.responses.toLocaleString($i18n.language)}</td
>
<td
class="px-2.5 py-1.5 text-right"
title={`${session.recorded_responses}/${session.responses} ${$i18n.t('responses with recorded usage')}`}
><div class="flex items-center justify-end gap-3">
<span class="w-20 h-1.5 shrink-0" aria-hidden="true">
{#if session.recorded_responses && overview && overview.max_tokens > 0}
<span
data-token-bar
class="block h-full"
style:width={`${(100 * session.tokens) / overview.max_tokens}%`}
style:background={session.model_count === 1 && session.model_id
? modelColor(session.model_id)
: 'var(--color-gray-400)'}
></span>
{/if}
</span>
<span class="min-w-12"
>{session.recorded_responses
? formatter.format(session.tokens)
: '-'}{#if session.recorded_responses > 0 && session.complete_responses < session.responses}<span
class="text-gray-400">*</span
>{/if}</span
>
</div></td
>
<td class="px-2.5 py-1.5 text-right">{session.errors}</td>
<td class="px-2.5 py-1.5 text-right">{span(session.span_seconds)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div
class="mt-3 flex items-center justify-between gap-3 text-[0.6875rem] text-gray-400 dark:text-gray-500"
>
<span
title={$i18n.t(
'One session per chat. Counts cover the selected date range; span includes idle time.'
)}>{$i18n.t('- usage not recorded · * partial usage')}</span
>
<div class="flex items-center gap-2">
<button
type="button"
aria-label={$i18n.t('Previous sessions')}
disabled={loading || page === 0}
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 disabled:opacity-30"
on:click={() => page--}><ChevronLeft className="size-3.5" /></button
><span>{$i18n.t('Page')} {page + 1}</span><button
type="button"
aria-label={$i18n.t('Next sessions')}
disabled={loading || error || !hasMore}
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 disabled:opacity-30"
on:click={() => page++}><ChevronRight className="size-3.5" /></button
>
</div>
</div>
</section>
{#if selected}
<Modal
show={true}
size="lg"
ariaLabel={$i18n.t('Session details')}
onRequestClose={closeSession}
>
<div bind:this={detailPanel} class="p-5">
<SessionDetail session={selected} {scope} {modelNames} onClose={closeSession} />
</div>
</Modal>
{/if}
</div>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import { getContext } from 'svelte';
import { SESSION_TOKEN_RANGES, type SessionOverview } from '$lib/apis/analytics/sessions';
export let overview: SessionOverview;
export let selectedRange: number | null = null;
export let onSelect: (index: number | null) => void;
$: counts = overview.token_distribution ?? [];
$: peak = Math.max(1, ...counts);
$: tickUnit = Math.max(1, 10 ** Math.floor(Math.log10(peak / 4)));
$: ceiling = Math.ceil(peak / 4 / tickUnit) * tickUnit * 4;
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
$: number = new Intl.NumberFormat($i18n.language, {
notation: 'compact',
maximumFractionDigits: 1
});
</script>
<section
class="mb-4 text-xs font-normal text-gray-500 dark:text-gray-400"
aria-label={$i18n.t('Session summary')}
>
<dl class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
{#each [{ label: $i18n.t('Sessions'), value: number.format(overview.sessions) }, { label: $i18n.t('Prompts'), value: number.format(overview.prompts) }, { label: $i18n.t('Recorded tokens'), value: overview.recorded_responses ? number.format(overview.tokens) + (overview.complete_responses < overview.responses ? '*' : '') : '-' }, { label: $i18n.t('Response errors'), value: overview.responses ? `${((overview.errors / overview.responses) * 100).toFixed(1)}%` : '-' }] as stat}
<div class="flex items-baseline gap-1.5 whitespace-nowrap">
<dt>{stat.label}</dt>
<dd class="tabular-nums text-gray-900 dark:text-gray-200">{stat.value}</dd>
</div>
{/each}
</dl>
{#if counts.some((count) => count > 0)}
<figure class="mt-4" aria-label={$i18n.t('Sessions by recorded token usage')}>
<figcaption class="flex items-center justify-between gap-3 mb-3 text-[0.6875rem]">
<span>{$i18n.t('Sessions by recorded token usage')}</span>
{#if selectedRange !== null}<button
type="button"
class="py-1 hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => onSelect(null)}>{$i18n.t('All sessions')}</button
>{:else}<span>{$i18n.t('Sessions')}</span>{/if}
</figcaption>
<div class="relative h-40 pl-12">
{#each [ceiling, ceiling * 0.75, ceiling / 2, ceiling * 0.25, 0] as count}
<div
class="absolute inset-x-0 flex items-center gap-2 pointer-events-none translate-y-1/2"
style:bottom={`${(100 * count) / ceiling}%`}
>
<span class="w-10 shrink-0 text-right text-[0.6875rem] tabular-nums"
>{number.format(count)}</span
><span class="h-px flex-1 bg-gray-100 dark:bg-gray-850/50"></span>
</div>
{/each}
<div class="relative flex h-full">
{#each SESSION_TOKEN_RANGES as range, index}
<button
type="button"
data-token-range
class="relative flex flex-1 min-w-0 items-end justify-center focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
disabled={!counts[index]}
aria-pressed={selectedRange === index}
aria-label={`${range.label} ${$i18n.t('Recorded tokens')}: ${counts[index]} ${$i18n.t('Sessions')}`}
on:click={() => onSelect(selectedRange === index ? null : index)}
>
<span
class="relative w-3/4 max-w-12 bg-gray-400 dark:bg-gray-500"
style:height={`${(100 * counts[index]) / ceiling}%`}
style:opacity={selectedRange === null || selectedRange === index ? 1 : 0.3}
>
{#if counts[index]}<span
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 w-max whitespace-nowrap text-[0.6875rem] tabular-nums text-gray-700 dark:text-gray-300"
>{number.format(counts[index])}</span
>{/if}
</span>
</button>
{/each}
</div>
</div>
<div class="ml-12 mt-2 flex text-[0.625rem] tabular-nums">
{#each SESSION_TOKEN_RANGES as range}<span
class="flex-1 min-w-0 text-center whitespace-nowrap"
>{range.label.replace('–<', '–')}</span
>{/each}
</div>
<p class="mt-3 text-[0.625rem] text-gray-400 dark:text-gray-500">
{$i18n.t('Select a range to filter sessions. Partial usage is included.')}
{#if overview.unrecorded_sessions}
{number.format(overview.unrecorded_sessions)}
{$i18n.t(
overview.unrecorded_sessions === 1
? 'session without recorded usage'
: 'sessions without recorded usage'
)}{/if}
</p>
</figure>
{/if}
</section>

View file

@ -0,0 +1,172 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { UsageBar } from '$lib/apis/analytics/sessions';
import { modelColor } from './UsageChart.svelte';
export let usage: UsageBar[];
export let onSelect: (offset: number) => void;
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
let width = 0;
let active = 0;
let plot: HTMLDivElement;
$: formatter = new Intl.NumberFormat($i18n.language, {
notation: 'compact',
maximumFractionDigits: 1
});
// Consecutive groups keep the full session visible with usable targets on narrow screens.
$: groupSize = Math.max(1, Math.ceil(usage.length / Math.max(1, Math.floor(width / 16))));
$: bars = group(usage, groupSize);
$: active = Math.min(active, Math.max(0, bars.length - 1));
$: highest = Math.max(1, ...bars.map(total));
$: unit = 10 ** Math.floor(Math.log10(highest));
$: ceiling = Math.ceil(highest / unit) * unit;
$: current = bars[active];
const total = (bar: UsageBar) => (bar.input_tokens ?? 0) + (bar.output_tokens ?? 0);
const partial = (bar: UsageBar) => bar.complete < bar.last - bar.first + 1;
const range = (bar: UsageBar) =>
`${bar.first === bar.last ? $i18n.t('Response') : $i18n.t('Responses')} ${bar.first}${bar.last > bar.first ? `–${bar.last}` : ''}`;
const amount = (value: number | null) =>
value == null ? '-' : value.toLocaleString($i18n.language);
const description = (bar: UsageBar) =>
`${range(bar)} · ${$i18n.t('Input')} ${amount(bar.input_tokens)} · ${$i18n.t('Output')} ${amount(bar.output_tokens)}${partial(bar) ? ` · ${$i18n.t('Partial usage')}` : ''}${bar.errors ? ` · ${bar.errors} ${$i18n.t('Response errors')}` : ''}`;
function group(points: UsageBar[], size: number): UsageBar[] {
const result: UsageBar[] = [];
for (let i = 0; i < points.length; i += size) {
const slice = points.slice(i, i + size);
const sum = (key: 'input_tokens' | 'output_tokens') =>
slice.some((p) => p[key] != null) ? slice.reduce((n, p) => n + (p[key] ?? 0), 0) : null;
result.push({
...slice[0],
last: slice[slice.length - 1].last,
input_tokens: sum('input_tokens'),
output_tokens: sum('output_tokens'),
recorded: slice.reduce((n, p) => n + p.recorded, 0),
complete: slice.reduce((n, p) => n + p.complete, 0),
errors: slice.reduce((n, p) => n + p.errors, 0),
model_id: slice.every((p) => p.model_id === slice[0].model_id) ? slice[0].model_id : null
});
}
return result;
}
function navigate(event: KeyboardEvent, index: number) {
let next = index;
if (event.key === 'ArrowRight') next = Math.min(index + 1, bars.length - 1);
else if (event.key === 'ArrowLeft') next = Math.max(index - 1, 0);
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = bars.length - 1;
else return;
event.preventDefault();
active = next;
plot.querySelectorAll<HTMLButtonElement>('[data-usage-bar]')[next]?.focus();
}
</script>
{#if bars.length}
<figure class="mb-4" aria-label={$i18n.t('Recorded tokens by response')}>
<figcaption
class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 mb-3 text-[0.6875rem]"
>
<span>{$i18n.t('Recorded tokens by response')}</span>
<span class="flex items-center gap-3">
<span class="flex items-center gap-1.5"
><span class="size-2 bg-gray-400 opacity-45"></span>{$i18n.t('Input')}</span
>
<span class="flex items-center gap-1.5"
><span class="size-2 bg-gray-400"></span>{$i18n.t('Output')}</span
>
{#if usage.some((bar) => bar.errors > 0)}<span class="flex items-center gap-1.5"
><span class="size-1.5 bg-gray-400 dark:bg-gray-500"></span>{$i18n.t(
'Response errors'
)}</span
>{/if}
</span>
</figcaption>
<div class="relative h-40 pl-12">
{#each [ceiling, ceiling * 0.75, ceiling / 2, ceiling * 0.25, 0] as value}
<div
class="absolute inset-x-0 flex items-center gap-2 pointer-events-none translate-y-1/2"
style:bottom={`${(100 * value) / ceiling}%`}
>
<span class="w-10 shrink-0 text-right text-[0.6875rem] tabular-nums"
>{formatter.format(value)}</span
>
<span class="h-px flex-1 bg-gray-100 dark:bg-gray-850/50"></span>
</div>
{/each}
<div
bind:this={plot}
bind:clientWidth={width}
class="relative flex h-full"
role="group"
aria-label={$i18n.t('Use arrow keys to explore the chart')}
>
{#each bars as bar, index (bar.first)}
<button
type="button"
data-usage-bar
class="relative flex-1 min-w-0 flex items-end justify-center focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
tabindex={index === active ? 0 : -1}
aria-label={description(bar)}
title={description(bar)}
on:mouseenter={() => (active = index)}
on:focus={() => (active = index)}
on:keydown={(event) => navigate(event, index)}
on:click={() => onSelect(bar.offset)}
>
<span
class="w-3/4 max-w-12 h-full flex flex-col-reverse"
aria-hidden="true"
style:color={bar.model_id ? modelColor(bar.model_id) : 'var(--color-gray-400)'}
>
{#if !bar.recorded}<span class="absolute bottom-0 text-[0.6875rem] leading-none"
>-</span
>
{:else if total(bar) === 0}<span class="h-px w-full bg-current"></span>
{:else}
<span
class="block shrink-0 bg-current opacity-45"
style:height={`${(100 * (bar.input_tokens ?? 0)) / ceiling}%`}
></span>
<span
class="block shrink-0 bg-current"
style:height={`${(100 * (bar.output_tokens ?? 0)) / ceiling}%`}
></span>
{/if}
</span>
{#if bar.errors}<span
aria-hidden="true"
class="absolute top-0 size-1.5 bg-gray-400 dark:bg-gray-500"
></span>{/if}
</button>
{/each}
</div>
</div>
<div class="ml-12 mt-2 flex justify-between text-[0.625rem] tabular-nums">
<span>{bars[0].first}</span><span>{$i18n.t('Response order')}</span><span
>{bars[bars.length - 1].last}</span
>
</div>
{#if current}
<div
class="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[0.6875rem] tabular-nums"
aria-live="polite"
>
<span class="text-gray-900 dark:text-gray-200">{range(current)}</span>
<span>{$i18n.t('Input')} {amount(current.input_tokens)}</span><span
>{$i18n.t('Output')} {amount(current.output_tokens)}</span
>
{#if partial(current)}<span
>{$i18n.t('Partial usage')} · {current.complete}/{current.last - current.first + 1}
{$i18n.t('complete')}</span
>{/if}
{#if current.errors}<span>{current.errors} {$i18n.t('Response errors')}</span>{/if}
</div>
{/if}
<p class="mt-1 text-[0.625rem] text-gray-400 dark:text-gray-500">
{$i18n.t(
'Select a bar to jump to its saved responses. Grouped bars sum consecutive responses.'
)}
{$i18n.t('- usage not recorded')}
</p>
</figure>
{/if}

View file

@ -0,0 +1,140 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { TraceItem } from '$lib/apis/analytics/sessions';
import { modelColor } from './UsageChart.svelte';
export let items: TraceItem[];
export let page: number;
export let label: (item: TraceItem) => string;
export let status: (item: TraceItem) => string;
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
const timed = (item: TraceItem) =>
item.started_at != null &&
Number.isFinite(item.started_at) &&
item.ended_at != null &&
Number.isFinite(item.ended_at) &&
item.ended_at >= item.started_at;
const measured = (item: TraceItem) =>
item.duration_seconds != null &&
Number.isFinite(item.duration_seconds) &&
item.duration_seconds >= 0;
const duration = (seconds: number) =>
seconds < 1
? `${Math.round(seconds * 1000)}ms`
: seconds < 60
? `${seconds.toFixed(1)}s`
: `${(seconds / 60).toFixed(1)}m`;
$: waterfall = items.some(timed);
$: sequence = !waterfall && !items.some(measured);
$: bars = sequence ? items : items.filter(waterfall ? timed : measured);
$: lanes = sequence
? [...new Set(bars.map(label))].map((name) => ({
name,
entries: bars.flatMap((item, index) => (label(item) === name ? [{ item, index }] : []))
}))
: bars.map((item, index) => ({ name: label(item), entries: [{ item, index }] }));
$: start = waterfall ? Math.min(...bars.map((item) => item.started_at!)) : 0;
$: end = waterfall
? Math.max(...bars.map((item) => item.ended_at!))
: Math.max(0, ...bars.map((item) => item.duration_seconds ?? 0));
$: span = end - start;
const elapsed = (item: TraceItem) =>
waterfall ? item.ended_at! - item.started_at! : item.duration_seconds!;
</script>
<figure class="mb-4" aria-label={$i18n.t('Execution timeline')}>
<figcaption class="flex justify-between gap-3 mb-2 text-[0.6875rem]">
<span
>{sequence
? $i18n.t('Recorded sequence')
: waterfall
? $i18n.t('Execution timeline')
: $i18n.t('Recorded duration')}</span
>
<span>{$i18n.t('Page')} {page + 1}</span>
</figcaption>
{#if bars.length}
<!-- Keyboard users must be able to scroll the bounded chart. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="max-h-48 overflow-y-auto scrollbar-hover focus-visible:outline focus-visible:outline-1 focus-visible:outline-gray-400"
tabindex="0"
role="region"
aria-label={$i18n.t('Execution timeline')}
>
<div
class="grid grid-cols-[6rem_minmax(0,1fr)_3rem] sm:grid-cols-[10rem_minmax(0,1fr)_3.5rem] gap-x-2 sticky top-0 z-10 bg-white dark:bg-gray-900 pb-1 text-[0.625rem] tabular-nums"
aria-hidden="true"
>
<span></span>
<div class="flex justify-between">
{#if sequence}
<span>1</span><span>{$i18n.t('Saved order')}</span><span>{bars.length}</span>
{:else}
<span>0s</span><span>{duration(span / 2)}</span><span>{duration(span)}</span>
{/if}
</div>
<span class="text-right">{sequence ? $i18n.t('Count') : ''}</span>
</div>
{#each lanes as lane, laneIndex (laneIndex)}
{@const item = lane.entries[0].item}
<div
data-waterfall-row
class="grid grid-cols-[6rem_minmax(0,1fr)_3rem] sm:grid-cols-[10rem_minmax(0,1fr)_3.5rem] gap-x-2 items-center min-h-7 text-[0.6875rem]"
title={sequence
? lane.name
: `${item.model_id || ''} · ${label(item)} · ${status(item)}${waterfall ? ` · ${duration(item.started_at! - start)}–${duration(item.ended_at! - start)}` : ''}`}
>
<span class="truncate"
>{lane.name}{!sequence && item.status === 'failed' ? ` · ${status(item)}` : ''}</span
>
<div class="relative h-7" aria-hidden="true">
{#each [0, 50, 100] as tick}<span
class="absolute inset-y-0 w-px bg-gray-100 dark:bg-gray-850/50"
style:left={`${tick}%`}
></span>{/each}
{#each lane.entries as entry (entry.index)}
<span
data-waterfall-bar={sequence ? undefined : ''}
data-sequence-mark={sequence ? '' : undefined}
class={sequence
? 'absolute top-2.5 size-2 -translate-x-1/2'
: 'absolute top-2 h-3 min-w-px max-w-full'}
style:left={sequence
? `${(100 * (entry.index + 0.5)) / bars.length}%`
: `${waterfall && span > 0 ? (100 * (item.started_at! - start)) / span : 0}%`}
style:width={sequence
? `min(0.5rem, ${80 / bars.length}%)`
: `${span > 0 ? (100 * elapsed(item)) / span : 0}%`}
title={sequence
? `#${entry.index + 1} · ${entry.item.model_id || ''} · ${status(entry.item)}`
: undefined}
style:background={entry.item.model_id
? modelColor(entry.item.model_id)
: 'var(--color-gray-400)'}
></span>
{/each}
</div>
<span class="text-right tabular-nums"
>{sequence ? lane.entries.length : duration(elapsed(item))}</span
>
</div>
{/each}
</div>
<p class="mt-2 text-[0.625rem] text-gray-400 dark:text-gray-500">
{sequence
? $i18n.t('Saved order only; spacing does not represent elapsed time.')
: waterfall
? $i18n.t('Time from first timed operation on this page.')
: $i18n.t('Start/end times not recorded; bars compare durations only.')}
{#if !sequence && bars.length < items.length}{items.length - bars.length}
{waterfall
? $i18n.t('items without recorded start/end times')
: $i18n.t('items without recorded durations')}{/if}
</p>
{:else}
<p class="text-[0.6875rem] text-gray-400 dark:text-gray-500">
{$i18n.t('No saved items match this filter.')}
</p>
{/if}
</figure>

View file

@ -446,6 +446,7 @@
scope="col"
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-0"
>
<span class="sr-only">{$i18n.t('Actions')}</span>
</th>
</tr>
</thead>

View file

@ -31,6 +31,7 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import AdminSettingField from './AdminSettingField.svelte';
import AdminSettingRow from './AdminSettingRow.svelte';
import AdminSettingSection from './AdminSettingSection.svelte';
@ -43,10 +44,13 @@
let showResetConfirm = false;
let showResetUploadDirConfirm = false;
let showReindexConfirm = false;
let showEmbeddingAdvanced = false;
let showExternalDocumentLoaderHeadersHint = false;
let RAG_EMBEDDING_ENGINE = '';
let RAG_EMBEDDING_MODEL = '';
let RAG_EMBEDDING_QUERY_PREFIX = '';
let RAG_EMBEDDING_CONTENT_PREFIX = '';
let RAG_EMBEDDING_BATCH_SIZE = 1;
let ENABLE_ASYNC_EMBEDDING = true;
let RAG_EMBEDDING_CONCURRENT_REQUESTS = 0;
@ -126,6 +130,8 @@
const payload: Parameters<typeof updateEmbeddingConfig>[1] = {
RAG_EMBEDDING_ENGINE: RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_MODEL: RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_QUERY_PREFIX,
RAG_EMBEDDING_CONTENT_PREFIX,
RAG_EMBEDDING_BATCH_SIZE: RAG_EMBEDDING_BATCH_SIZE,
ENABLE_ASYNC_EMBEDDING: ENABLE_ASYNC_EMBEDDING,
RAG_EMBEDDING_CONCURRENT_REQUESTS: RAG_EMBEDDING_CONCURRENT_REQUESTS
@ -257,6 +263,18 @@
return;
}
}
if (RAGConfig.MISTRAL_OCR_PARAMS) {
try {
JSON.parse(RAGConfig.MISTRAL_OCR_PARAMS);
} catch {
toast.error(
$i18n.t('Invalid JSON format in {{NAME}}', {
NAME: $i18n.t('Mistral OCR Parameters')
})
);
return;
}
}
const res = await updateRAGConfig(localStorage.token, {
...RAGConfig,
@ -284,6 +302,11 @@
: RAGConfig.CONTENT_EXTRACTION_SUPPORTED_MEDIA_MIME_TYPES.split(',')
.map((mimeType: string) => mimeType.trim())
.filter((mimeType: string) => mimeType !== ''),
MISTRAL_OCR_PARAMS:
typeof RAGConfig.MISTRAL_OCR_PARAMS === 'string' &&
RAGConfig.MISTRAL_OCR_PARAMS.trim() !== ''
? JSON.parse(RAGConfig.MISTRAL_OCR_PARAMS)
: {},
MINERU_PARAMS:
typeof RAGConfig.MINERU_PARAMS === 'string' && RAGConfig.MINERU_PARAMS.trim() !== ''
? JSON.parse(RAGConfig.MINERU_PARAMS)
@ -301,6 +324,9 @@
if (embeddingConfig) {
RAG_EMBEDDING_ENGINE = embeddingConfig.RAG_EMBEDDING_ENGINE;
RAG_EMBEDDING_MODEL = embeddingConfig.RAG_EMBEDDING_MODEL;
RAG_EMBEDDING_QUERY_PREFIX = embeddingConfig.RAG_EMBEDDING_QUERY_PREFIX ?? '';
RAG_EMBEDDING_CONTENT_PREFIX = embeddingConfig.RAG_EMBEDDING_CONTENT_PREFIX ?? '';
showEmbeddingAdvanced = Boolean(RAG_EMBEDDING_QUERY_PREFIX || RAG_EMBEDDING_CONTENT_PREFIX);
RAG_EMBEDDING_BATCH_SIZE = embeddingConfig.RAG_EMBEDDING_BATCH_SIZE ?? 1;
ENABLE_ASYNC_EMBEDDING = embeddingConfig.ENABLE_ASYNC_EMBEDDING ?? true;
RAG_EMBEDDING_CONCURRENT_REQUESTS = embeddingConfig.RAG_EMBEDDING_CONCURRENT_REQUESTS ?? 0;
@ -332,6 +358,11 @@
? JSON.stringify(config.MINERU_PARAMS ?? {}, null, 2)
: config.MINERU_PARAMS;
config.MISTRAL_OCR_PARAMS =
typeof config.MISTRAL_OCR_PARAMS === 'object'
? JSON.stringify(config.MISTRAL_OCR_PARAMS ?? {}, null, 2)
: config.MISTRAL_OCR_PARAMS;
config.EXTERNAL_DOCUMENT_LOADER_HEADERS =
typeof config.EXTERNAL_DOCUMENT_LOADER_HEADERS === 'object'
? Object.keys(config.EXTERNAL_DOCUMENT_LOADER_HEADERS ?? {}).length > 0
@ -794,6 +825,17 @@
>
<Switch bind:state={RAGConfig.MISTRAL_OCR_USE_BASE64} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingField
label={$i18n.t('settings.admin.documents.mistralOcrParams.label')}
description={$i18n.t('settings.admin.documents.mistralOcrParams.description')}
>
<Textarea
className={textareaClass}
bind:value={RAGConfig.MISTRAL_OCR_PARAMS}
placeholder={$i18n.t('Enter additional parameters in JSON format')}
minSize={100}
/>
</AdminSettingField>
{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'paddleocr_vl'}
<div class="grid grid-cols-1 gap-x-3 gap-y-2.5 sm:grid-cols-2">
<AdminSettingField
@ -1225,6 +1267,40 @@
/>
</AdminSettingRow>
{/if}
<Collapsible
title={$i18n.t('Advanced')}
bind:open={showEmbeddingAdvanced}
buttonClassName="w-full py-1 text-xs text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
>
<div slot="content" class="flex flex-col gap-2.5 pt-2.5">
<AdminSettingField
label={$i18n.t('settings.admin.documents.embeddingQueryPrefix.label')}
description={$i18n.t('settings.admin.documents.embeddingQueryPrefix.description')}
forId="embedding-query-prefix"
>
<input
id="embedding-query-prefix"
class={inputClass}
placeholder={$i18n.t('Optional')}
bind:value={RAG_EMBEDDING_QUERY_PREFIX}
/>
</AdminSettingField>
<AdminSettingField
label={$i18n.t('settings.admin.documents.embeddingContentPrefix.label')}
description={$i18n.t('settings.admin.documents.embeddingContentPrefix.description')}
forId="embedding-content-prefix"
>
<input
id="embedding-content-prefix"
class={inputClass}
placeholder={$i18n.t('Optional')}
bind:value={RAG_EMBEDDING_CONTENT_PREFIX}
/>
</AdminSettingField>
</div>
</Collapsible>
</AdminSettingSection>
{/if}
@ -1388,8 +1464,6 @@
placeholder={$i18n.t('Enter Score')}
bind:value={RAGConfig.RELEVANCE_THRESHOLD}
autocomplete="off"
min="0.0"
title={$i18n.t('The score should be a value between 0.0 (0%) and 1.0 (100%).')}
/>
</AdminSettingField>

View file

@ -103,7 +103,11 @@
features: {
...current.features,
enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION,
enable_tool_permissions: chatConfig.ENABLE_TOOL_PERMISSIONS
enable_tool_permissions: chatConfig.ENABLE_TOOL_PERMISSIONS,
enable_title_generation: taskConfig.ENABLE_TITLE_GENERATION,
enable_follow_up_generation: taskConfig.ENABLE_FOLLOW_UP_GENERATION,
enable_tags_generation: taskConfig.ENABLE_TAGS_GENERATION,
enable_autocomplete_generation: taskConfig.ENABLE_AUTOCOMPLETE_GENERATION
}
}
: current

View file

@ -94,7 +94,7 @@
let availableModelIds = new Set<string>();
let filteredModels = [];
let selectedModelId = null;
let selectedModelId: string | null = null;
let showManageModal = false;
let showResetModal = false;
@ -1343,6 +1343,7 @@
admin
edit
model={models.find((m) => m.id === selectedModelId)}
providerModel={allModels.find((m) => m.id === selectedModelId)}
preset={false}
onAccessUpdate={(model) => {
savedModels = [...savedModels.filter((m) => m.id !== model.id), model];

View file

@ -362,7 +362,9 @@
</button>
</th>
<th scope="col" class="px-2.5 py-1.5 font-normal text-right"></th>
<th scope="col" class="px-2.5 py-1.5 font-normal text-right"
><span class="sr-only">{$i18n.t('Actions')}</span></th
>
</tr>
</thead>
<tbody class="">

View file

@ -165,7 +165,7 @@
}}
>
<div
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-normal rounded-full bg-transparent dark:text-gray-200"
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-normal bg-transparent dark:text-gray-200"
>
<button
class="min-w-fit p-1.5 {tab === ''

View file

@ -6,11 +6,21 @@
import { goto } from '$app/navigation';
import { updateUserById, getUserGroupsById, revokeUserSessions } from '$lib/apis/users';
import {
updateUserById,
getUserGroupsById,
revokeUserSessions,
rotateUserAPIKeyById,
deleteUserAPIKeyById
} from '$lib/apis/users';
import { copyToClipboard } from '$lib/utils';
import Modal from '$lib/components/common/Modal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
let showRevokeConfirmation = false;
let showDeleteAPIKeyConfirmation = false;
let showRotateAPIKeyConfirmation = false;
let rotatedAPIKey = '';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import XMark from '$lib/components/icons/XMark.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
@ -24,11 +34,14 @@
export let selectedUser;
export let sessionUser;
$: if (show) {
$: if (show && selectedUser) {
init();
} else {
rotatedAPIKey = '';
}
const init = () => {
rotatedAPIKey = '';
if (selectedUser) {
_user = { ...selectedUser, password: '' };
loadUserGroups();
@ -67,6 +80,42 @@
};
</script>
<ConfirmDialog
bind:show={showRotateAPIKeyConfirmation}
title={$i18n.t('Rotate API Key?')}
message={$i18n.t(
'This will replace the current API key. Integrations using the old key will stop working.'
)}
confirmLabel={$i18n.t('Rotate')}
on:confirm={async () => {
const target = _user;
try {
const key = await rotateUserAPIKeyById(localStorage.token, selectedUser.id);
if (show && target === _user) rotatedAPIKey = key;
toast.success($i18n.t('API Key created.'));
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
}}
/>
<ConfirmDialog
bind:show={showDeleteAPIKeyConfirmation}
title={$i18n.t('Delete API Key?')}
message={$i18n.t('This will revoke the current API key.')}
confirmLabel={$i18n.t('Delete')}
on:confirm={async () => {
const target = _user;
try {
await deleteUserAPIKeyById(localStorage.token, selectedUser.id);
if (target === _user) rotatedAPIKey = '';
toast.success($i18n.t('API Key deleted.'));
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
}}
/>
<ConfirmDialog
bind:show={showRevokeConfirmation}
title={$i18n.t('Sign out all devices?')}
@ -231,6 +280,46 @@
/>
</div>
</div>
<div class="flex items-center justify-between w-full">
<div class="text-xs text-gray-500">{$i18n.t('API Key')}</div>
<div class="flex items-center gap-3">
<button
type="button"
class="text-xs text-gray-500 transition-colors hover:text-gray-700 disabled:opacity-50 dark:text-gray-400 dark:hover:text-gray-200"
on:click={() => {
showRotateAPIKeyConfirmation = true;
}}>{$i18n.t('Rotate')}</button
>
<button
type="button"
class="text-xs text-gray-500 transition-colors hover:text-gray-700 disabled:opacity-50 dark:text-gray-400 dark:hover:text-gray-200"
on:click={() => {
showDeleteAPIKeyConfirmation = true;
}}>{$i18n.t('Delete')}</button
>
</div>
</div>
{#if rotatedAPIKey}
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<SensitiveInput variant="settings" value={rotatedAPIKey} readOnly={true} />
<button
type="button"
class="shrink-0 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200"
aria-label={$i18n.t('Copy API Key')}
on:click={async () => {
if (await copyToClipboard(rotatedAPIKey))
toast.success($i18n.t('Copied to clipboard'));
}}>{$i18n.t('Copy')}</button
>
</div>
<p class="text-xs text-gray-500">
{$i18n.t(
'Copy this key now. It will not be shown here again after you close this dialog.'
)}
</p>
</div>
{/if}
</div>
</div>
</div>

View file

@ -142,7 +142,7 @@
loading = true;
try {
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
const endDays = overnight && !endsPastMidnight() ? 0 : endDayOffset;
const endDays = endsPastMidnight() ? Math.max(endDayOffset, 1) : overnight ? 0 : endDayOffset;
const shiftedEndDate = endDate
? nsToDateStr(dateTimeToNs(startDate, '12:00') + endDays * 24 * 60 * 60 * 1000 * NS)
: '';
@ -222,14 +222,18 @@
type="text"
bind:value={title}
placeholder={$i18n.t('Event title')}
aria-label={$i18n.t('Event title')}
/>
</div>
<div class="px-4 pb-2 flex flex-col gap-2.5">
<!-- Calendar -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Calendar')}</div>
<div id="event-calendar-label" class="mb-1 text-xs text-gray-500">
{$i18n.t('Calendar')}
</div>
<select
aria-labelledby="event-calendar-label"
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={calendarId}
>
@ -247,18 +251,21 @@
type="date"
class="bg-transparent outline-hidden dark:scheme-dark"
bind:value={startDate}
aria-label={$i18n.t('Start date')}
/>
{#if !allDay}
<input
type="time"
class="bg-transparent outline-hidden dark:scheme-dark"
bind:value={startTime}
aria-label={$i18n.t('Start time')}
/>
<span class="text-gray-300 dark:text-gray-600">–</span>
<input
type="time"
class="bg-transparent outline-hidden dark:scheme-dark"
bind:value={endTime}
aria-label={$i18n.t('End time')}
/>
{/if}
<label class="flex items-center gap-1.5 cursor-pointer text-xs text-gray-400 ml-auto">
@ -270,8 +277,11 @@
<!-- Location -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Location')}</div>
<div id="event-location-label" class="mb-1 text-xs text-gray-500">
{$i18n.t('Location')}
</div>
<input
aria-labelledby="event-location-label"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
placeholder={$i18n.t('Add location')}
bind:value={location}
@ -280,8 +290,11 @@
<!-- Reminder -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Reminder')}</div>
<div id="event-reminder-label" class="mb-1 text-xs text-gray-500">
{$i18n.t('Reminder')}
</div>
<select
aria-labelledby="event-reminder-label"
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={alertMinutes}
>
@ -297,8 +310,9 @@
<!-- Repeat -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Repeat')}</div>
<div id="event-repeat-label" class="mb-1 text-xs text-gray-500">{$i18n.t('Repeat')}</div>
<select
aria-labelledby="event-repeat-label"
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={repeatFrequency}
>
@ -313,8 +327,11 @@
<!-- Description -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div id="event-description-label" class="mb-1 text-xs text-gray-500">
{$i18n.t('Description')}
</div>
<textarea
aria-labelledby="event-description-label"
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 resize-none min-h-[4rem]"
placeholder={$i18n.t('Add description')}
bind:value={description}

View file

@ -36,7 +36,7 @@
const map: Record<string, CalendarEventModel[]> = {};
for (const e of filteredEvents) {
const startMs = e.start_at / NS;
const endMs = (e.end_at || e.start_at) / NS;
const endMs = Math.max(startMs, (e.end_at || e.start_at) / NS - 1);
// Get local midnight for event start/end
const startDate = new Date(startMs);
const endDate = new Date(endMs);

View file

@ -90,6 +90,7 @@
let loaded = false;
let draggedOver = false;
let shiftKey = false;
let recording = false;
let content = '';
@ -542,11 +543,25 @@
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftKey = true;
}
if (event.key === 'Escape') {
draggedOver = false;
}
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftKey = false;
}
};
const handleBlur = () => {
shiftKey = false;
};
const onDragOver = (e: DragEvent) => {
e.preventDefault();
@ -792,6 +807,8 @@
}, 100);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
let dropzoneElement: HTMLElement | null = null;
const initialize = async () => {
@ -812,6 +829,8 @@
isDestroyed = true;
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
if (dropzoneElement) {
dropzoneElement.removeEventListener('dragover', onDragOver);
@ -1085,7 +1104,7 @@
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0
)}
largeTextAsFile={$settings?.largeTextAsFile ?? false}
largeTextAsFile={($settings?.largeTextAsFile ?? false) && !shiftKey}
floatingMenuPlacement={'top-start'}
{suggestions}
on:pasted={resolveInputMentions}
@ -1167,7 +1186,7 @@
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type === 'text/plain') {
if ($settings?.largeTextAsFile ?? false) {
if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
const text = clipboardData.getData('text/plain');
if (text.length > PASTED_TEXT_CHARACTER_LIMIT) {

View file

@ -23,7 +23,7 @@
let messages = null;
let searchAnchor = '';
let loadVersion = 0;
let loading = null;
let top = false;
let messagesContainerElement = null;
@ -49,7 +49,7 @@
};
const initHandler = async (anchor = '') => {
const version = ++loadVersion;
loading = null;
searchAnchor = anchor;
messages = null;
top = false;
@ -59,19 +59,16 @@
typingUsersTimeout = {};
if (channel) {
const loaded = await getChannelThreadMessages(
localStorage.token,
channel.id,
threadId,
0,
50,
{ around: anchor }
).catch((error) => {
toast.error(String(error));
loading = getChannelThreadMessages(localStorage.token, channel.id, threadId, 0, 50, {
around: anchor
}).catch((error) => {
if (request === loading) toast.error(String(error));
return [];
});
if (version !== loadVersion) return;
messages = loaded;
const request = loading;
const loaded = await request;
if (request !== loading) return;
messages = loaded ?? [];
if (messages.length < 50) {
top = true;
@ -96,9 +93,15 @@
const type = event?.data?.type ?? null;
const data = event?.data?.data ?? null;
if (messages === null && type !== 'typing') {
const request = loading;
await request;
if (request !== loading) return;
}
if (type === 'message') {
if ((data?.parent_id ?? null) === threadId) {
if (messages) {
if (messages && !messages.some((message) => message.id === data.id)) {
messages = [{ ...data, temp_id: null }, ...messages];
if (typingUsers.find((user) => user.id === event.user.id)) {
@ -215,6 +218,7 @@
});
onDestroy(() => {
loading = null;
$socket?.off('events:channel', channelEventHandler);
});
</script>

View file

@ -151,6 +151,9 @@
export let embeddedDraftKey = '';
export let suggestedPrompts = [];
export let selectedText = '';
export let notePageId = '';
export let noteFiles = [];
export let onBeforeNoteSubmit: (() => Promise<void>) | null = null;
export let onInsertToNote: ((content: string) => void) | null = null;
export let onCloseEmbedded: (() => void) | null = null;
export let onNewEmbeddedChat: (() => void | Promise<void>) | null = null;
@ -257,6 +260,7 @@
const fallbackModels = normalizeSelectedModels(selectedModels);
if (!equal(fallbackModels, selectedModels)) {
selectedModels = fallbackModels;
if (!loading && !modelDefaults) setDefaults();
}
}
}
@ -353,7 +357,8 @@
let selectedToolIds: string[] = [];
let selectedSkillIds: string[] = [];
let selectedFilterIds: string[] = [];
let pendingOAuthTools = [];
let pendingOAuthTools: { id: string; name: string; serverId: string; authType: string | null }[] =
[];
let imageGenerationEnabled = false;
let webSearchEnabled = false;
@ -363,6 +368,11 @@
let pendingWebSearchPrompt: string | null = null;
let webSearchConfirmed = false;
// Before webSearchActive, so it sees the new defaults in the same update
$: if (!loading && modelDefaults && !equal(selectedModelIds, defaultsModelIds)) {
setDefaults(true);
}
$: {
const currentModels = atSelectedModel?.id ? [atSelectedModel.id] : selectedModels;
const allModelsSupportWebSearch =
@ -820,16 +830,6 @@
}
};
let oldSelectedModelIds = [''];
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
onSelectedModelIdsChange();
}
const onSelectedModelIdsChange = () => {
resetInput();
oldSelectedModelIds = structuredClone(selectedModelIds);
};
const mergeFiles = (current, incoming) => {
const seen = new Set();
return [...(incoming ?? []), ...(current ?? [])].filter((file) => {
@ -864,10 +864,16 @@
}
};
const withSelectedText = (text: string) =>
embedded && selectedText?.trim()
? `${text}\n\nSelected note text for replace_note_content operations:\n${selectedText.trim()}`
: text;
const withSelectedText = (text: string) => {
if (!embedded) return text;
return (
text +
(notePageId ? `\n\nActive note page_id: ${notePageId}` : '') +
(selectedText?.trim()
? `\n\nSelected note text for replace_note_content operations:\n${selectedText.trim()}`
: '')
);
};
const noteChatDebug = (message: string, data: Record<string, unknown> = {}) => {
if (!embedded) return;
console.info('[note-chat]', message, {
@ -898,7 +904,16 @@
saveControlsTimer = setTimeout(saveControls, 400);
}
let chatLoadController = new AbortController();
const startChatLoad = () => {
chatLoadController.abort();
chatLoadController = new AbortController();
return chatLoadController.signal;
};
onDestroy(() => chatLoadController.abort());
const navigateHandler = async () => {
const signal = startChatLoad();
modelDefaults = null;
cancelBrowserInteractions();
bridge?.end();
noteChatDebug('navigateHandler start');
@ -911,6 +926,7 @@
clearTimeout(saveControlsTimer);
await saveControls();
if (signal.aborted) return;
loading = true;
prompt = '';
@ -924,49 +940,18 @@
imageGenerationEnabled = false;
codeInterpreterEnabled = false;
const storageChatInput = sessionStorage.getItem(
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
);
const loaded = chatIdProp ? await loadChat() : false;
noteChatDebug('loadChat completed inside navigateHandler', { loaded });
if (loaded) {
await tick();
loading = false;
noteChatDebug('embedded chat loading false');
if (!$page.url.searchParams.has('message')) window.setTimeout(() => scrollToBottom(), 0);
await tick();
// Mark chat read when initially loading it
if (chatIdProp && !$temporaryChatEnabled) {
updateLastReadAt(chatIdProp);
}
// Process any queued requests if the chat is idle
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
const isIdle = !lastMessage || lastMessage.role !== 'assistant' || lastMessage.done;
if (isIdle) {
await processNextInQueue(chatIdProp);
}
if (!(await restoreChatInput(storageChatInput))) {
await setDefaults();
}
messageInput?.focus({ preventScroll: true });
if (chatIdProp) {
await loadChat(signal);
} else if (!embedded) {
await goto('/');
} else {
loading = false;
console.warn('[note-chat] embedded load failed; clearing spinner', {
chatIdProp,
activeChatId: $chatId
});
}
};
const initEmbeddedDraft = async () => {
chatLoadController.abort();
modelDefaults = null;
terminalContext = null;
clearTimeout(saveControlsTimer);
await saveControls();
@ -1098,153 +1083,147 @@
lastTerminalSkillSelector = $selectedTerminalId;
}
let defaultsModelIds: string[] = [];
let modelDefaults: Record<string, any> | null = null;
let settingDefaults: Promise<void> | null = null;
const setDefaults = async () => {
// Concurrent callers must wait, or the earlier run overwrites what they write afterwards
if (settingDefaults) return settingDefaults;
const setDefaults = async (preserveSelections = false) => {
const modelIds = atSelectedModel ? [atSelectedModel.id] : [...selectedModels];
const id = $chatId;
while (settingDefaults) await settingDefaults;
let resolveDefaults = () => {};
settingDefaults = new Promise<void>((resolve) => (resolveDefaults = resolve));
try {
if (!$tools) {
tools.set(await getTools(localStorage.token));
}
if (!$functions) {
functions.set(await getFunctions(localStorage.token));
}
if (!$skills) {
skills.set(await getSkills(localStorage.token));
}
if (selectedModels.length !== 1 && !atSelectedModel) {
const comparedModels = selectedModels
.filter((id) => id)
.map((id) => $models.find((m) => m.id === id));
const isSharedDefaultFeature = (feature) =>
comparedModels.length > 0 &&
comparedModels.every(
(model) =>
model?.info?.meta?.capabilities?.[feature] &&
model?.info?.meta?.defaultFeatureIds?.includes(feature)
);
if (
isSharedDefaultFeature('image_generation') &&
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
) {
imageGenerationEnabled = true;
}
if (
isSharedDefaultFeature('web_search') &&
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
) {
webSearchEnabled = true;
}
if (
isSharedDefaultFeature('code_interpreter') &&
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
) {
codeInterpreterEnabled = true;
}
if (!$tools) tools.set(await getTools(localStorage.token));
if (!$functions) functions.set(await getFunctions(localStorage.token));
if (!$skills) skills.set(await getSkills(localStorage.token));
if ($tools === null || $functions === null || $skills === null) return;
if (
id !== $chatId ||
!equal(modelIds, atSelectedModel ? [atSelectedModel.id] : selectedModels)
)
return;
const currentModels: any[] = modelIds.map((id) => $models.find((model) => model.id === id));
if (!currentModels.length || currentModels.some((model) => !model)) return;
const supports = (feature: string) =>
currentModels.every((model) => model.info?.meta?.capabilities?.[feature] ?? true);
const featureAvailable = (feature: 'web_search' | 'image_generation' | 'code_interpreter') =>
supports(feature) &&
$config?.features?.[`enable_${feature}`] &&
($user?.role === 'admin' || $user?.permissions?.features?.[feature]);
const defaultFeature = (feature: 'web_search' | 'image_generation' | 'code_interpreter') =>
featureAvailable(feature) &&
currentModels.every((model) => model.info?.meta?.defaultFeatureIds?.includes(feature));
const terminalId = currentModels[0]?.info?.meta?.terminalId;
const defaults: Record<string, any> = {
selectedToolIds: [
...new Set(
currentModels.flatMap(
(model) =>
model.info?.meta?.toolIds?.filter(
(id: string) =>
selectedToolIds.includes(id) || ($tools as any[]).some((tool) => tool.id === id)
) ??
($settings as any)?.tools ??
[]
)
)
],
selectedSkillIds: [
...new Set(currentModels.flatMap((model) => model.info?.meta?.skillIds ?? []))
].filter(
(id) =>
selectedSkillIds.includes(id) ||
$skills.some((skill) => skill.id === id && skill.is_active)
),
selectedFilterIds: [
...new Set(currentModels.flatMap((model) => model.info?.meta?.defaultFilterIds ?? []))
],
webSearchEnabled: !!defaultFeature('web_search'),
imageGenerationEnabled: !!defaultFeature('image_generation'),
codeInterpreterEnabled: !!defaultFeature('code_interpreter'),
terminalId:
terminalId &&
isTerminalAvailable(terminalId) &&
currentModels.every((model) => model.info?.meta?.terminalId === terminalId)
? terminalId
: preserveSelections
? null
: $selectedTerminalId
};
if (preserveSelections) {
const current: Record<string, any> = {
selectedToolIds: [...selectedToolIds, ...pendingOAuthTools.map((tool) => tool.id)],
selectedSkillIds,
selectedFilterIds,
webSearchEnabled,
imageGenerationEnabled,
codeInterpreterEnabled,
terminalId: $selectedTerminalId
};
modelDefaults ??= structuredClone(defaults);
for (const key of Object.keys(defaults)) {
const previous = modelDefaults[key];
if (Array.isArray(previous)) {
const added = current[key].filter((id: string) => !previous.includes(id));
const removed = previous.filter((id: string) => !current[key].includes(id));
// Keep deselected defaults in the baseline so later presets cannot re-enable them.
modelDefaults[key] = [
...new Set([...removed, ...defaults[key].filter((id: string) => !added.includes(id))])
];
defaults[key] = [
...new Set([...defaults[key].filter((id: string) => !removed.includes(id)), ...added])
];
} else if (current[key] !== previous) {
defaults[key] = current[key];
} else {
modelDefaults[key] = defaults[key];
}
}
} else {
modelDefaults = structuredClone(defaults);
}
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
if (model) {
// Set Default Tools
if (model?.info?.meta?.toolIds) {
const defaultIds = [
...new Set(
[...(model?.info?.meta?.toolIds ?? [])].filter((id) =>
$tools.find((t) => t.id === id)
)
)
];
// Separate unauthenticated OAuth tools
const unauthed = [];
const authed = [];
for (const id of defaultIds) {
const tool = $tools.find((t) => t.id === id);
if (tool && tool.authenticated === false) {
const parts = id.split(':');
const serverId = parts.at(-1) ?? id;
const authType =
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null;
unauthed.push({ id, name: tool.name ?? id, serverId, authType });
} else {
authed.push(id);
}
}
selectedToolIds = authed;
pendingOAuthTools = unauthed;
await continueOAuthRedirect();
} else if ($settings?.tools) {
selectedToolIds = $settings.tools;
} else {
selectedToolIds = selectedToolIds.filter((id) => !id.startsWith('direct_server:'));
}
// Set Default Skills
if (model?.info?.meta?.skillIds) {
selectedSkillIds = [
...new Set(
[...(model?.info?.meta?.skillIds ?? [])].filter((id) =>
($skills ?? []).find((s) => s.id === id && s.is_active)
)
)
];
} else {
selectedSkillIds = [];
}
// Set Default Filters (Toggleable only)
if (model?.info?.meta?.defaultFilterIds) {
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
model?.filters?.find((f) => f.id === id)
);
}
// Set Default Features
if (model?.info?.meta?.defaultFeatureIds) {
if (
model.info?.meta?.capabilities?.['image_generation'] &&
$config?.features?.enable_image_generation &&
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
) {
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
}
if (
model.info?.meta?.capabilities?.['web_search'] &&
$config?.features?.enable_web_search &&
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
) {
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
}
if (
model.info?.meta?.capabilities?.['code_interpreter'] &&
$config?.features?.enable_code_interpreter &&
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
) {
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
}
}
// Set Default Terminal — only if the referenced terminal actually exists
if (model?.info?.meta?.terminalId) {
const tid = model.info.meta.terminalId;
if (isTerminalAvailable(tid)) {
selectedTerminalId.set(tid);
}
}
defaults.selectedFilterIds = defaults.selectedFilterIds.filter((id: string) =>
currentModels.every((model) =>
model.filters?.some((filter: { id: string }) => filter.id === id)
)
);
defaults.webSearchEnabled &&= !!featureAvailable('web_search');
defaults.imageGenerationEnabled &&= !!featureAvailable('image_generation');
defaults.codeInterpreterEnabled &&= !!featureAvailable('code_interpreter');
if (!currentModels.some((model) => model.info?.meta?.capabilities?.terminal ?? true)) {
defaults.terminalId = null;
}
if (defaults.terminalId) defaults.codeInterpreterEnabled = false;
defaultsModelIds = modelIds;
({
selectedToolIds,
selectedSkillIds,
selectedFilterIds,
webSearchEnabled,
imageGenerationEnabled,
codeInterpreterEnabled
} = defaults);
selectedTerminalId.set(defaults.terminalId);
pendingOAuthTools = [];
selectedToolIds = selectedToolIds.filter((id) => {
const tool = ($tools as any[]).find((tool) => tool.id === id);
if (tool?.authenticated !== false) return true;
const parts = id.split(':');
pendingOAuthTools.push({
id,
name: tool.name ?? id,
serverId: parts.at(-1) ?? id,
authType: parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null
});
return false;
});
await continueOAuthRedirect();
} finally {
settingDefaults = null;
resolveDefaults();
@ -1405,6 +1384,16 @@
(taskIds?.length || generating);
if (!local || (!ownActive && (!baseline || baseline[id] === local))) {
history.messages[id] = { ...local, ...message };
// Keep recovered replies finished unless their task is active or has been replaced.
if (
local?.done === true &&
message.done === false &&
local.meta?.task_id &&
local.meta.task_id === message.meta?.task_id &&
!taskIds?.includes(local.meta.task_id)
) {
history.messages[id].done = true;
}
}
if (local) {
history.messages[id] = {
@ -1425,14 +1414,15 @@
if (!id || $temporaryChatEnabled) return Promise.resolve();
if (chatRefresh?.id === id) return chatRefresh.promise;
const baseline = { ...history.messages };
const signal = chatLoadController.signal;
const promise = (async () => {
try {
const fresh = await getChatById(localStorage.token, id);
if ($chatId !== id || joinedChatId !== id) return;
const fresh = await getChatById(localStorage.token, id, signal);
if ($chatId !== id || joinedChatId !== id || signal.aborted) return;
chat = fresh;
mergeChatMessages(fresh.chat.history, baseline);
} catch (error) {
if ($chatId === id && joinedChatId === id) {
if ($chatId === id && joinedChatId === id && !signal.aborted) {
clearPresence();
chat = null;
history = { messages: {}, currentId: null };
@ -2629,6 +2619,10 @@
};
const initNewChat = async () => {
chatLoadController.abort();
modelDefaults = null;
clearTimeout(saveControlsTimer);
await saveControls();
if ($chatId) terminalContext = null;
cancelBrowserInteractions();
bridge?.end();
@ -2880,7 +2874,7 @@
messageInput?.focus({ preventScroll: true });
};
const loadChat = async () => {
const loadChat = async (signal = startChatLoad()) => {
if (chatIdProp && chatIdProp !== $chatId) {
terminalContext = null;
history = { messages: {}, currentId: null };
@ -2896,26 +2890,25 @@
temporaryChatEnabled.set(false);
}
chat = await getChatById(localStorage.token, $chatId).catch(async (error) => {
const id = $chatId;
const loadedChat = await getChatById(localStorage.token, id, signal).catch(async (error) => {
console.error('[note-chat] getChatById failed', {
chatIdProp,
activeChatId: $chatId,
error
});
if (!embedded) {
await goto('/');
}
return null;
});
if (signal.aborted) return;
noteChatDebug('getChatById completed', {
found: !!chat,
chatId: chat?.id,
hasChatPayload: !!chat?.chat,
title: chat?.title
found: !!loadedChat,
chatId: loadedChat?.id,
hasChatPayload: !!loadedChat?.chat,
title: loadedChat?.title
});
if (chat) {
tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => {
if (loadedChat) {
const loadedTags = await getTagsById(localStorage.token, id, signal).catch(async (error) => {
console.warn('[note-chat] getTagsById failed; continuing without tags', {
chatIdProp,
activeChatId: $chatId,
@ -2923,6 +2916,9 @@
});
return [];
});
if (signal.aborted) return;
chat = loadedChat;
tags = loadedTags;
noteChatDebug('getTagsById completed', { tagCount: tags?.length ?? 0 });
const chatContent = chat.chat;
@ -2958,8 +2954,6 @@
noteChatDebug('normalized empty selected models after load', { selectedModels });
}
oldSelectedModelIds = structuredClone(selectedModels);
chatContent.history ??= convertMessagesToHistory(chatContent.messages);
// Keep live updates received while the chat and tags were loading.
for (const [id, message] of Object.entries(history.messages)) {
@ -2989,6 +2983,7 @@
autoScroll = true;
await tick();
if (signal.aborted) return;
// Mark all non-current assistant messages as done
if (history.currentId) {
@ -3009,7 +3004,7 @@
// work (follow-ups, title gen) that shouldn't block the input.
const activeTaskIds = taskIds;
const currentMessage = history.currentId ? history.messages[history.currentId] : null;
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, $chatId)
const pendingTaskIds = await getTaskIdsByChatId(localStorage.token, id, signal)
.then((res) => res?.task_ids ?? [])
.catch((error) => {
console.warn('[note-chat] getTaskIdsByChatId failed; continuing without tasks', {
@ -3019,31 +3014,30 @@
});
return [];
});
if (signal.aborted) return;
noteChatDebug('task reconciliation completed', {
pendingTaskCount: pendingTaskIds.length,
hasCurrentMessage: !!currentMessage
});
if (taskIds !== activeTaskIds) {
noteChatDebug('task ids changed during load; aborting stale load');
return;
}
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 (pendingTaskIds.length === 0) {
for (const message of Object.values(history.messages)) {
if (
message?.role === 'assistant' &&
(message.user_id ?? chat?.user_id) === $user?.id &&
!message.done &&
!messageHasPendingAskUser(message)
) {
message.done = true;
history = history;
if (taskIds === activeTaskIds) {
if (pendingTaskIds.length > 0 && !responseComplete) {
taskIds = pendingTaskIds;
} else {
taskIds = null;
// No active tasks and message incomplete → generation was interrupted
if (pendingTaskIds.length === 0) {
for (const message of Object.values(history.messages)) {
if (
message?.role === 'assistant' &&
(message.user_id ?? chat?.user_id) === $user?.id &&
!message.done &&
!messageHasPendingAskUser(message)
) {
message.done = true;
history = history;
}
}
}
}
@ -3051,6 +3045,27 @@
await tick();
if (signal.aborted) return;
if (loading) {
if (chatIdProp && !$temporaryChatEnabled) updateLastReadAt(chatIdProp);
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
if (!lastMessage || lastMessage.role !== 'assistant' || lastMessage.done) {
await processNextInQueue(chatIdProp);
}
if (signal.aborted) return;
const restored = await restoreChatInput(
sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)
);
if (signal.aborted) return;
await setDefaults(restored);
if (signal.aborted) return;
loading = false;
await tick();
if (signal.aborted) return;
if (!$page.url.searchParams.has('message')) scrollToBottom();
messageInput?.focus({ preventScroll: true });
}
return true;
} else {
console.warn('[note-chat] chat response missing chat payload', {
@ -3058,13 +3073,16 @@
activeChatId: $chatId,
chat
});
return null;
}
}
console.warn('[note-chat] no chat returned from getChatById', {
chatIdProp,
activeChatId: $chatId
});
chat = null;
if (!embedded) await goto('/');
else loading = false;
return false;
};
const scrollToBottom = async (behavior = 'auto') => {
@ -3145,7 +3163,11 @@
entry.files.some((file: any) => file.type === 'filesystem')
);
const queuedMessages =
boundary === 0 ? queue.slice(0, 1) : boundary < 0 ? [...queue] : queue.slice(0, boundary);
embedded || boundary === 0
? queue.slice(0, 1)
: boundary < 0
? [...queue]
: queue.slice(0, boundary);
const queuedMessageIds = new Set(queuedMessages.map((m) => m.id));
try {
const combinedPrompt = queuedMessages.map((m) => m.prompt).join('\n\n');
@ -3754,6 +3776,7 @@
atSelectedModel = undefined;
selectedModels = [model.id];
saveControls(selectedModels);
saveSessionSelectedModels();
toast.success(`Model switched to: ${model.id}`);
messageInput?.setText('');
@ -3849,6 +3872,14 @@
bridge: bridgeRequest = null
}: { _raw?: boolean; bridge?: { userMessageId: string; modelId: string } | null } = {}
): Promise<BridgeSubmission> => {
if (embedded && onBeforeNoteSubmit) {
try {
await onBeforeNoteSubmit();
} catch (error) {
toast.error(String(error));
return { status: 'rejected' };
}
}
if (processing === 'uploading') return { status: 'deferred' };
emitTyping(false);
if (bridgeRequest && (selectedModelIds.length !== 1 || !bridgeRequest.modelId))
@ -4051,12 +4082,11 @@
_history = structuredClone(_history);
const responseMessageIds: Record<PropertyKey, string> = {};
// If modelId is provided, use it, else use selected model
let selectedModelIds = modelId
? [modelId]
: atSelectedModel !== undefined
? [atSelectedModel.id]
: selectedModels;
if (!modelId && atSelectedModel) {
selectedModels = [atSelectedModel.id];
atSelectedModel = undefined;
}
const selectedModelIds = modelId ? [modelId] : selectedModels;
if (!modelId && history.messages[parentId]) {
history.messages[parentId].models = [...selectedModelIds];
}
@ -4400,7 +4430,11 @@
stop: getStopTokens()
},
files: (files?.length ?? 0) > 0 ? files : undefined,
files: embedded
? [...(files ?? []), ...noteFiles]
: (files?.length ?? 0) > 0
? files
: undefined,
filter_ids:
$config?.features?.enable_functions && selectedFilterIds.length > 0
@ -4687,7 +4721,8 @@
scrollToBottom();
}
await sendMessage(history, userMessage.id, {
const currentModels = atSelectedModel ? [atSelectedModel.id] : selectedModels;
const options = {
...(suggestionPrompt
? {
messages: createMessagesList(history, message.id),
@ -4695,14 +4730,16 @@
regenerationMessageId: message.id
}
: {}),
...((userMessage?.models ?? [...selectedModels]).length > 1
...((userMessage.models ?? currentModels).length > 1 && currentModels.length > 1
? {
// If multiple models are selected, use the model from the message
modelId: message.model,
// Regenerate the selected column with its current model.
modelId: currentModels[message.modelIdx ?? 0] ?? message.model,
modelIdx: message.modelIdx
}
: {})
});
};
userMessage.models = [...currentModels];
await sendMessage(history, userMessage.id, options);
}
};
@ -4858,20 +4895,23 @@
});
};
const saveControls = async () => {
const saveControls = async (models: string[] | null = null) => {
if (isShared) return;
if (!$chatId || $temporaryChatEnabled) return;
const loaded = chat?.chat ?? {};
if (equal(chatFiles, loaded.files ?? [])) return;
if (!$chatId || $temporaryChatEnabled || chat?.id !== $chatId) return;
// Model choices are passed explicitly; automatic fallbacks must not be saved.
if (!models?.length || !models.every(Boolean)) models = null;
if (!models && equal(chatFiles, chat.chat?.files ?? [])) return;
const id = $chatId;
const res = await updateChatById(localStorage.token, $chatId, {
files: chatFiles
const res = await updateChatById(localStorage.token, id, {
files: chatFiles,
...(models ? { models } : {})
}).catch((err) => {
console.error('[controls autosave]', err);
return null;
});
// Refresh the dedupe baseline so a later revert still saves.
if (res) chat = res;
if (res && $chatId === id) chat = res;
};
const MAX_DRAFT_LENGTH = 5000;
@ -5236,6 +5276,7 @@
chat={{
id: $chatId,
archived: chat?.archived ?? false,
folder_id: chat?.folder_id,
chat: {
title: $chatTitle,
models: selectedModels,
@ -5375,6 +5416,7 @@
{history}
{taskIds}
bind:selectedModels
onModelSelect={saveControls}
bind:files
bind:prompt
bind:autoScroll
@ -5472,6 +5514,7 @@
{history}
{taskIds}
bind:selectedModels
onModelSelect={saveControls}
bind:files
bind:prompt
bind:autoScroll
@ -5536,6 +5579,7 @@
bind:selectedModelIdx
{history}
bind:selectedModels
onModelSelect={saveControls}
bind:messageInput
bind:files
bind:prompt

View file

@ -62,7 +62,7 @@
let largeScreen = false;
let dragged = false;
let mounted = false;
let controlsWidth = 350;
let controlsWidth = 300;
let viewportWidth = 0;
// Tab state for Controls+Files panel
@ -162,7 +162,7 @@
};
onMount(() => {
const mediaQuery = window.matchMedia('(min-width: 1024px)');
const mediaQuery = window.matchMedia('(min-width: 768px)');
mediaQuery.addEventListener('change', handleMediaQuery);
handleMediaQuery(mediaQuery);
@ -347,10 +347,8 @@
<ResizableSidePanel
open={$showControls}
bind:width={controlsWidth}
minWidth={350}
minWidth={300}
minSiblingWidth={360}
closeOnDragBelowMinWidth
onClose={() => showControls.set(false)}
storageKey="chatControlsSize"
className="h-full z-10 bg-white dark:bg-gray-900"
>

View file

@ -193,7 +193,7 @@
const text = source?.text ?? selection.toString();
if (!text.trim()) return;
if (new TextEncoder().encode(text).length > 16384) {
toast.error('Select less text (maximum 16 KiB).');
toast.error($i18n.t('Select less text (maximum 16 KiB).'));
return;
}
const page =
@ -1475,8 +1475,7 @@
uploading = false;
if (selectedTerminal?.url === terminal.url && (chatId ?? undefined) === sessionId) {
invalidateTreeCache(destination);
if (currentPath === destination && !selectedFile)
await loadDir(destination, { preserveTree: true });
if (currentPath === destination && !selectedFile) await refreshBrowser();
}
}
};
@ -1523,7 +1522,7 @@
result ? $i18n.t('Folder created') : $i18n.t('Failed to create folder')
);
invalidateTreeCache(currentPath);
await loadDir(currentPath, { preserveTree: true });
await refreshBrowser();
};
// ── File creation ────────────────────────────────────────────────────
@ -1560,7 +1559,7 @@
result ? $i18n.t('File created') : $i18n.t('Failed to create file')
);
invalidateTreeCache(currentPath);
await loadDir(currentPath, { preserveTree: true });
await refreshBrowser();
};
// ── Delete ───────────────────────────────────────────────────────────
@ -1575,7 +1574,7 @@
: $i18n.t('Failed to delete {{name}}', { name })
);
invalidateTreeCache(currentPath, path);
await loadDir(currentPath, { preserveTree: true });
await refreshBrowser();
};
const requestDelete = (path: string, name: string) => {
@ -1660,7 +1659,7 @@
toast.success($i18n.t('Renamed to {{name}}', { name: newName }));
}
invalidateTreeCache(currentPath, oldPath);
await loadDir(currentPath, { preserveTree: true });
await refreshBrowser();
};
// ── Multi-select ────────────────────────────────────────────────────
@ -1737,7 +1736,7 @@
);
invalidateTreeCache(currentPath, ...paths);
clearSelection();
await loadDir(currentPath, { preserveTree: true });
await refreshBrowser();
};
const bulkDownload = async () => {
@ -2275,15 +2274,16 @@
role="status"
class="flex flex-wrap items-center gap-2 px-2 py-1.5 text-xs text-gray-500 dark:text-gray-400"
>
File updated on disk. Your local changes are preserved. <button
{$i18n.t('File updated on disk. Your local changes are preserved.')}
<button
class="underline"
on:click={() => openEntry({ name: selectedFile!.split('/').pop()!, type: 'file' })}
>Reload</button
>{$i18n.t('Reload')}</button
>
<button
class="underline"
on:click={() => filePreviewRef.save().catch((e) => toast.error(String(e)))}
>Save local changes</button
>{$i18n.t('Save local changes')}</button
>
</div>{/if}
<!-- Content -->
@ -2632,6 +2632,7 @@
onLongPress={enterSelectionMode}
onToggleExpand={toggleExpand}
showDate={sortBy === 'date'}
{showHidden}
parentWritable={currentWritable}
/>
{/each}

View file

@ -58,6 +58,7 @@
export let onLongPress: () => void = () => {};
export let onToggleExpand: (path: string) => void = () => {};
export let showDate: boolean = false;
export let showHidden: boolean = false;
export let parentWritable = true;
$: entryPath =
@ -130,7 +131,10 @@
observer.disconnect();
// Only inspect visible folders; failed reads leave their contents unknown.
void listFiles(url, key, path, session).then((result) => {
if (!disposed && result) folderItemCount = result.entries.length;
if (!disposed && result)
folderItemCount = result.entries.filter(
(item) => showHidden || !item.name.startsWith('.')
).length;
});
});
observer.observe(node);

View file

@ -42,7 +42,8 @@
showFileNavDir,
selectedTerminalId,
TTSWorker,
temporaryChatEnabled
temporaryChatEnabled,
visibleControls
} from '$lib/stores';
import {
@ -165,6 +166,7 @@
export let atSelectedModel: Model | undefined = undefined;
export let selectedModels: [''];
export let onModelSelect: (models: string[]) => void = () => {};
let selectedModelIds = [];
$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
@ -927,6 +929,18 @@
($config?.audio?.stt?.engine !== 'web' &&
($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))));
$: if (webSearchCapableModels.length < selectedModelIds.length) webSearchEnabled = false;
$: if (imageGenerationCapableModels.length < selectedModelIds.length)
imageGenerationEnabled = false;
$: if (codeInterpreterCapableModels.length < selectedModelIds.length)
codeInterpreterEnabled = false;
$: {
const filterIds = selectedFilterIds.filter((id) =>
toggleFilters.some((filter: { id: string }) => filter.id === id)
);
if (filterIds.length !== selectedFilterIds.length) selectedFilterIds = filterIds;
}
// Disable code interpreter when terminal is active (mutually exclusive)
$: if ($selectedTerminalId && codeInterpreterEnabled) {
codeInterpreterEnabled = false;
@ -2562,7 +2576,7 @@
{/if}
<div class="ml-1 flex gap-1.5 shrink-0">
{#if (selectedToolIds ?? []).length > 0}
{#if (selectedToolIds ?? []).length > 0 && $visibleControls['chat.selected_tools']}
<Tooltip
content={$i18n.t('{{COUNT}} Available Tools', {
COUNT: (selectedToolIds ?? []).length
@ -2585,7 +2599,7 @@
</Tooltip>
{/if}
{#if (selectedSkillIds ?? []).length > 0}
{#if (selectedSkillIds ?? []).length > 0 && $visibleControls['chat.selected_skills']}
<Tooltip
content={$i18n.t('{{COUNT}} Available Skills', {
COUNT: (selectedSkillIds ?? []).length
@ -2610,7 +2624,7 @@
{#each selectedFilterIds as filterId (filterId)}
{@const filter = toggleFilters.find((f) => f.id === filterId)}
{#if filter}
{#if filter && $visibleControls['chat.selected_filters']}
<Tooltip
content={resolveLocalizedFunction(
filter,
@ -2679,7 +2693,7 @@
{/if}
{/each}
{#if webSearchEnabled && showWebSearchButton}
{#if webSearchEnabled && showWebSearchButton && $visibleControls['chat.selected_web_search']}
<Tooltip content={$i18n.t('Web Search')} placement="top">
<button
aria-label={$i18n.t('Web Search')}
@ -2699,7 +2713,7 @@
</Tooltip>
{/if}
{#if imageGenerationEnabled && showImageGenerationButton}
{#if imageGenerationEnabled && showImageGenerationButton && $visibleControls['chat.selected_image_generation']}
<Tooltip content={$i18n.t('Image')} placement="top">
<button
aria-label={$i18n.t('Image')}
@ -2719,7 +2733,7 @@
</Tooltip>
{/if}
{#if codeInterpreterEnabled && showCodeInterpreterButton}
{#if codeInterpreterEnabled && showCodeInterpreterButton && $visibleControls['chat.selected_code_interpreter']}
<Tooltip content={$i18n.t('Code Interpreter')} placement="top">
<button
aria-label={codeInterpreterEnabled
@ -2778,6 +2792,7 @@
<ModelSelector
bind:this={modelSelector}
bind:selectedModels
{onModelSelect}
showSetDefault={!history?.currentId}
placement="auto"
align="end"
@ -2786,7 +2801,7 @@
</div>
<ModelControls selectedModels={selectedModelIds} />
{#if isActive && prompt === '' && files.length === 0}
{#if isActive && (history.messages[history.currentId]?.user_id ?? $_user?.id) === $_user?.id && prompt === '' && files.length === 0}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Stop')}>
<button

View file

@ -3,7 +3,7 @@
import { onDestroy, onMount, tick, getContext } from 'svelte';
import { decodeString } from '$lib/utils';
import { getChatList, getChatListBySearchText } from '$lib/apis/chats';
import { getChatList, getChatListBySearchText, getChatSearchSnippet } from '$lib/apis/chats';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
@ -17,7 +17,7 @@
let loaded = false;
let items = [];
let items: { id: string; name: string; updated_at: number; snippet?: string | null }[] = [];
let selectedIdx = 0;
let query = '';
@ -29,6 +29,18 @@
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let requestId = 0;
const loadSnippet = async (item: { id: string; snippet?: string | null }) => {
if (!searchedQuery.trim() || item.snippet !== undefined) return;
item.snippet = null;
try {
item.snippet = await getChatSearchSnippet(localStorage.token, item.id, searchedQuery);
items = items;
} catch (error) {
item.snippet = undefined;
console.error(error);
}
};
// Only re-run the search when the query actually changes. Flipping `initialized`
// after the initial load would otherwise trigger a second, identical request.
$: if (initialized && query !== searchedQuery) {
@ -82,6 +94,7 @@
.map((item) => {
return {
...item,
snippet: undefined,
type: 'chat',
name: item.title
};
@ -145,6 +158,7 @@
.locale($i18n.language)
.fromNow()}
placement="top-start"
tippyOptions={{ onShown: () => loadSnippet(item) }}
>
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}

View file

@ -516,43 +516,45 @@
</div>
</Loader>
{/if}
<ul role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false">
{#each messages as message, messageIdx (message.id)}
<Message
{chatId}
bind:history
{selectedModels}
messageId={message.id}
idx={messageIdx}
{user}
{setInputText}
{gotoMessage}
{showPreviousMessage}
{showNextMessage}
{updateChat}
{editMessage}
{deleteMessage}
{rateMessage}
{actionMessage}
{saveMessage}
{submitMessage}
{regenerateResponse}
{continueResponse}
{mergeResponses}
{addMessages}
{onToolCallResolved}
{forkHandler}
{allowDelete}
{triggerScroll}
{readOnly}
{compactPreview}
{editCodeBlock}
{topPadding}
{onInsertToNote}
{shareMode}
/>
{/each}
</ul>
<div role="log" aria-live="polite" aria-relevant="additions" aria-atomic="false">
<ul>
{#each messages as message, messageIdx (message.id)}
<Message
{chatId}
bind:history
{selectedModels}
messageId={message.id}
idx={messageIdx}
{user}
{setInputText}
{gotoMessage}
{showPreviousMessage}
{showNextMessage}
{updateChat}
{editMessage}
{deleteMessage}
{rateMessage}
{actionMessage}
{saveMessage}
{submitMessage}
{regenerateResponse}
{continueResponse}
{mergeResponses}
{addMessages}
{onToolCallResolved}
{forkHandler}
{allowDelete}
{triggerScroll}
{readOnly}
{compactPreview}
{editCodeBlock}
{topPadding}
{onInsertToNote}
{shareMode}
/>
{/each}
</ul>
</div>
</section>
<div class="pb-18" />
{#if bottomPadding}

View file

@ -19,7 +19,9 @@
try {
const note = await getNoteById(localStorage.token, noteId);
if (note) {
title = note.title || '';
const pageId = new URL(href, window.location.origin).searchParams.get('page');
const page = note.data?.pages?.find((p) => p.id === pageId);
title = page ? `${note.title} / ${page.title}` : note.title || '';
if (note.user_id) {
try {
@ -48,7 +50,7 @@
on:click|preventDefault|stopPropagation={() => {
try {
const url = new URL(href, window.location.origin);
goto(url.pathname);
goto(url.pathname + url.search + url.hash);
} catch {
// fallback
}

View file

@ -1,3 +1,3 @@
<div class=" self-center text-[0.9375rem] font-normal line-clamp-1 flex gap-1 items-center">
<h3 class=" self-center text-[0.9375rem] font-normal line-clamp-1 flex gap-1 items-center">
<slot />
</div>
</h3>

View file

@ -270,7 +270,7 @@
speaking = true;
if ($config.audio.tts.engine === '') {
if ($config.audio.tts.engine === '' && $settings.audio?.tts?.engine !== 'browser-kokoro') {
let voices = [];
const getVoicesLoop = setInterval(() => {
voices = speechSynthesis.getVoices();
@ -1205,7 +1205,7 @@
</Tooltip>
{/if}
{#if message.usage && $visibleControls['chat.usage']}
{#if Object.keys(message.usage ?? {}).some((key) => key !== 'incomplete') && $visibleControls['chat.usage']}
{@const usage = JSON.stringify(message.usage, null, 2)
.replace(/"([^(")"]+)":/g, '$1:')
.slice(1, -1)

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { getContext } from 'svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import Image from '$lib/components/common/Image.svelte';
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
import TerminalOutputFile from './TerminalOutputFile.svelte';
import ToolOutputFile from './ToolOutputFile.svelte';
import { resolveChatMessageToolCall } from '$lib/apis/chats';
import { settings } from '$lib/stores';
import { toast } from 'svelte-sonner';
@ -38,6 +40,7 @@
export let onPreview: any = () => {};
export let onToolCallResolved: any = () => {};
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
const getDetailTitle = (detailToken: OutputDetailToken): any => detailToken.summary;
const getDetailAttributes = (detailToken: OutputDetailToken): any => detailToken.attributes;
let resolvingCallId = '';
@ -100,6 +103,8 @@
{:else}
<div class="whitespace-pre-wrap text-[0.9375rem]">{displayItem.text}</div>
{/if}
{:else if displayItem.type === 'image'}
<Image src={displayItem.src} alt={$i18n.t('Generated Image')} />
{:else if displayItem.type === 'detail_group'}
<ConsecutiveDetailsGroup
id={`${id}-${displayItem.id}`}
@ -169,7 +174,7 @@
</ConsecutiveDetailsGroup>
{:else if displayItem.type === 'file'}
{#if displayItem.item?.displayed || $settings?.terminalFileDisplay === 'inline'}
<TerminalOutputFile item={displayItem.item} {chatId} />
<ToolOutputFile item={displayItem.item} {chatId} />
{/if}
{:else if displayItem.type === 'context_compaction'}
<ContextCompactionLabel summary={displayItem.summary} {compactPreview} />

View file

@ -15,6 +15,7 @@
terminalServers
} from '$lib/stores';
import { downloadFileBlob, downloadFilePreview, readFile } from '$lib/apis/terminal';
import { getFileContentById } from '$lib/apis/files';
import FilePreview from '$lib/components/chat/FileNav/FilePreview.svelte';
import Icon from '$lib/components/chat/FileNav/Icon.svelte';
import { fileIconName } from '$lib/components/chat/FileNav/fileIcon';
@ -51,13 +52,18 @@
const SQLITE_EXTS = new Set(['db', 'sqlite', 'sqlite3', 'db3']);
const OFFICE_EXTS = new Set(['docx', 'xlsx', 'xls', 'pptx']);
$: path = String(item?.full_path || item?.path || '');
$: fileId =
item?.source === 'open_webui'
? (String(item.path ?? '').match(/^\/api\/v1\/files\/([^/]+)\/content$/)?.[1] ?? '')
: '';
// Content URLs have no extension; use the filename to select the preview.
$: path = String(fileId ? item.name : item?.full_path || item?.path || '');
$: name = String(item?.name || path.split('/').filter(Boolean).at(-1) || 'file');
$: targetPage = normalizeDocumentTargetPage(item?.page);
$: selector = item?.terminal_selector;
$: terminal = resolveTerminal();
$: unavailable = !terminal;
$: previewKey = terminal ? `${terminal.url}|${terminal.key}|${path}|${chatId}` : '';
$: unavailable = !terminal && !fileId;
$: previewKey = fileId || (terminal ? `${terminal.url}|${terminal.key}|${path}|${chatId}` : '');
$: isPerChatTerminal =
($terminalServers ?? []).find((server: any) => server.id === selector)?.contexts?.chat
?.context_id === 'chat_id';
@ -74,7 +80,7 @@
getExt(path) === 'svg'
? 'h-96'
: 'h-72';
$: if (expanded && terminal && path && previewKey !== loadedKey && !loading) {
$: if (expanded && !unavailable && path && previewKey !== loadedKey && !loading) {
void loadPreview(previewKey);
}
@ -125,6 +131,12 @@
}
async function blobForPreview() {
if (fileId) {
const data = await getFileContentById(fileId).catch(() => null);
return data
? { blob: new Blob([data], { type: item?.content_type ?? '' }), filename: name }
: null;
}
if (!terminal) return null;
return downloadFileBlob(terminal.url, terminal.key, path, chatId || undefined);
}
@ -167,12 +179,9 @@
const arrayBuffer = await result.blob.arrayBuffer();
fileSqliteData = arrayBuffer;
} else if (ext === 'docx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
path,
chatId || undefined
);
const preview = terminal
? await downloadFilePreview(terminal.url, terminal.key, path, chatId || undefined)
: null;
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
@ -191,12 +200,9 @@
await loadExcelSheet(excelSheetNames[0]);
}
} else if (ext === 'pptx') {
const preview = await downloadFilePreview(
terminal.url,
terminal.key,
path,
chatId || undefined
);
const preview = terminal
? await downloadFilePreview(terminal.url, terminal.key, path, chatId || undefined)
: null;
if (preview) {
filePdfData = await preview.blob.arrayBuffer();
} else {
@ -208,6 +214,10 @@
fileOfficeSlides = resultImages.images;
}
}
} else if (fileId) {
const result = await blobForPreview();
if (!result) throw new Error(t('Preview failed'));
fileContent = await result.blob.text();
} else if (terminal) {
fileContent = await readFile(terminal.url, terminal.key, path, chatId || undefined);
}
@ -225,8 +235,8 @@
}
async function downloadFile() {
if (!terminal || !path) return;
const result = await downloadFileBlob(terminal.url, terminal.key, path, chatId || undefined);
if (unavailable || !path) return;
const result = await blobForPreview();
if (!result) {
toast.error(t('Download failed'));
return;
@ -274,15 +284,17 @@
>
<Icon name="download" size={13} />
</button>
<button
type="button"
class="mr-1 flex size-6 shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-700 disabled:opacity-40 disabled:hover:text-gray-400 dark:text-gray-500 dark:hover:text-gray-200 dark:disabled:hover:text-gray-500"
disabled={unavailable}
on:click|stopPropagation={openInFiles}
aria-label={t('Open')}
>
<Icon name="external-link" size={13} />
</button>
{#if !fileId}
<button
type="button"
class="mr-1 flex size-6 shrink-0 items-center justify-center rounded text-gray-400 transition-colors hover:text-gray-700 disabled:opacity-40 disabled:hover:text-gray-400 dark:text-gray-500 dark:hover:text-gray-200 dark:disabled:hover:text-gray-500"
disabled={unavailable}
on:click|stopPropagation={openInFiles}
aria-label={t('Open')}
>
<Icon name="external-link" size={13} />
</button>
{/if}
</div>
{#if expanded}

View file

@ -50,6 +50,11 @@ export type OutputDisplayItem =
id: string;
text: string;
}
| {
type: 'image';
id: string;
src: string;
}
| {
type: 'detail_single';
id: string;
@ -181,14 +186,15 @@ function getInlineFileFromToolOutput(callItem?: OutputItem, resultItem?: OutputI
}
const result = parseJSONStringValue(getToolResultText(resultItem)) as Record<string, unknown>;
const isTerminalFile = result?.source === 'open_terminal' && !!result.terminal_selector;
const isOpenWebUIFile = result?.source === 'open_webui';
if (
!result ||
typeof result !== 'object' ||
result.type !== 'file' ||
result.source !== 'open_terminal' ||
result.exists === false ||
!result.path ||
!result.terminal_selector
result.exists === false ||
!(isTerminalFile || isOpenWebUIFile)
) {
return null;
}
@ -437,6 +443,20 @@ export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDispla
return;
}
if (item.type === 'image_generation_call') {
if (typeof item.result === 'string' && item.result) {
flushDetails();
displayItems.push({
type: 'image',
id: item.id ?? `image-${index}`,
src: item.result.startsWith('data:')
? item.result
: `data:image/${item.output_format || 'png'};base64,${item.result}`
});
}
return;
}
const fallbackText = getMessageText(item);
if (fallbackText.trim()) {
flushDetails();

View file

@ -10,6 +10,7 @@
const i18n = getContext('i18n');
export let selectedModels = [''];
export let onModelSelect: (models: string[]) => void = () => {};
export let disabled = false;
export let showSetDefault = true;
@ -85,6 +86,7 @@
{disabled}
bind:compareEnabled={compareModels}
bind:values={selectedModels}
{onModelSelect}
/>
</div>
</div>

View file

@ -75,6 +75,9 @@
: ''}"
data-arrow-selected={index === selectedModelIdx}
data-value={item.value}
on:focus={() => {
selectedModelIdx = index;
}}
on:click={() => {
onClick();
}}

View file

@ -11,7 +11,7 @@
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
import { onMount, getContext, tick } from 'svelte';
import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
import { deleteModelById } from '$lib/apis/models';
@ -55,11 +55,11 @@
import ModelItem from './ModelItem.svelte';
const i18n: any = getContext('i18n');
const dispatch = createEventDispatcher();
export let id = '';
export let value: string | null = '';
export let values: string[] | null = null;
export let onModelSelect: (models: string[]) => void = () => {};
export let compareEnabled = false;
export let multipleEnabled = false;
export let disabled = false;
@ -417,6 +417,7 @@
if (!enabled && values) {
values = [primaryValue || selectedValues[0] || ''];
value = values[0];
onModelSelect(values);
}
};
@ -433,11 +434,13 @@
values = nextValues.length ? nextValues : [item.value];
value = values[0];
onModelSelect(values);
return;
}
values = [item.value];
value = item.value;
onModelSelect(values);
show = false;
window.setTimeout(focusChatInput, 0);
return;
@ -1106,7 +1109,7 @@
{#each filteredItems.slice(visibleStart, visibleEnd) as item, i (item.value)}
{@const index = visibleStart + i}
<ModelItem
{selectedModelIdx}
bind:selectedModelIdx
{item}
{index}
value={primaryValue}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getContext, tick } from 'svelte';
import { toast } from 'svelte-sonner';
import {
@ -7,6 +7,7 @@
artifactContents,
banners,
chatId,
chatTitle as _chatTitle,
config,
mobile,
settings,
@ -37,6 +38,8 @@
import ChatCheck from '../icons/ChatCheck.svelte';
import Knobs from '../icons/Knobs.svelte';
import { isTemporaryChatId } from '$lib/utils/chatId';
import { updateChatById } from '$lib/apis/chats';
import { refreshChatList, refreshFolderChatLists } from '$lib/stores/chatList';
import ChatParticipants from '$lib/components/common/ChatParticipants.svelte';
const i18n: any = getContext('i18n');
@ -68,6 +71,45 @@
let showShareChatModal = false;
let showDownloadChatModal = false;
let confirmEdit = false;
let chatTitle = '';
let chatTitleInput: HTMLInputElement;
const renameHandler = async () => {
chatTitle = title || chat?.chat?.title || '';
confirmEdit = true;
await tick();
setTimeout(() => {
chatTitleInput?.focus();
chatTitleInput?.select();
}, 0);
};
const editChatTitle = async () => {
if (!confirmEdit) return;
confirmEdit = false;
const newTitle = chatTitle.trim();
if (newTitle === (title || chat?.chat?.title)) return;
if (!newTitle) {
toast.error($i18n.t('Title cannot be an empty string.'));
return;
}
const res = await updateChatById(localStorage.token, chat.id, { title: newTitle }).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (!res) return;
_chatTitle.set(newTitle);
await refreshChatList(localStorage.token, { refreshPinned: true });
await refreshFolderChatLists();
};
</script>
<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
@ -122,11 +164,32 @@
>
{#if chat?.id}
<div class="flex max-w-full min-w-0 items-center gap-2 mr-2">
<h1
class="min-w-0 truncate py-1 text-left text-[0.9375rem] font-normal text-gray-700 dark:text-gray-300"
>
{title || chat?.chat?.title || $i18n.t('New Chat')}
</h1>
{#if confirmEdit}
<input
bind:this={chatTitleInput}
bind:value={chatTitle}
class="min-w-0 flex-1 bg-transparent py-1 text-[0.9375rem] font-normal text-gray-700 outline-hidden dark:text-gray-300"
aria-label={$i18n.t('Title')}
on:keydown={(e) => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') {
e.preventDefault();
editChatTitle();
} else if (e.key === 'Escape') {
e.preventDefault();
confirmEdit = false;
}
}}
on:blur={editChatTitle}
/>
{:else}
<h1
class="min-w-0 truncate py-1 text-left text-[0.9375rem] font-normal text-gray-700 dark:text-gray-300"
>
{title || chat?.chat?.title || $i18n.t('New Chat')}
</h1>
{/if}
{#if shareEnabled && chat && (chat.id || $temporaryChatEnabled) && (scrollToTop || ($artifactContents ?? []).length > 0 || (!readOnly && !$temporaryChatEnabled) || $user?.role === 'admin' || ($user?.permissions?.chat?.export ?? true))}
<Menu
@ -137,6 +200,7 @@
shareHandler={() => {
showShareChatModal = !showShareChatModal;
}}
{renameHandler}
archiveChatHandler={() => {
archiveChatHandler(chat.id);
}}

View file

@ -46,6 +46,7 @@
export let atSelectedModel: Model | undefined;
export let selectedModels: [''];
export let onModelSelect: (models: string[]) => void = () => {};
export let history;
export let chatId = '';
@ -262,6 +263,7 @@
bind:this={messageInput}
{history}
bind:selectedModels
{onModelSelect}
bind:files
bind:prompt
bind:autoScroll

View file

@ -170,7 +170,7 @@
<div>
<!-- <div class="mb-1">
<div
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-normal rounded-full bg-transparent py-1 touch-auto pointer-events-auto"
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-normal bg-transparent py-1 touch-auto pointer-events-auto"
>
<button
class="min-w-fit p-1.5 {selectedTab === 'knowledge'

View file

@ -133,9 +133,10 @@
const model_id = 'onnx-community/Kokoro-82M-v1.0-ONNX';
const { KokoroTTS } = await import('kokoro-js');
const adapter = await navigator?.gpu?.requestAdapter().catch(() => null);
TTSModel = await KokoroTTS.from_pretrained(model_id, {
dtype: TTSEngineConfig.dtype, // Options: "fp32", "fp16", "q8", "q4", "q4f16"
device: !!navigator?.gpu ? 'webgpu' : 'wasm', // Detect WebGPU
device: adapter ? 'webgpu' : 'wasm', // Detect WebGPU
progress_callback: (e) => {
TTSModelProgress = e;
console.log(e);

View file

@ -17,6 +17,7 @@
import { settings, user } from '$lib/stores';
import { canManageChats, canUseTemporaryChats } from '$lib/utils/settings-access';
import Switch from '$lib/components/common/Switch.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import ShortcutItem from '../ShortcutItem.svelte';
type ShortcutEntry = {
@ -243,6 +244,15 @@
>
{formatChord(chord)}
</button>
<button
class="rounded-lg p-0.5 text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
aria-label={$i18n.t('Remove')}
title={$i18n.t('Remove')}
on:click={() => setBinding(id, '')}
>
<XMark className="size-3" strokeWidth="2" />
</button>
{:else}
<button
class="text-[0.625rem] text-gray-500 transition hover:text-gray-400 dark:text-gray-600"

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