mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 09:31:21 +00:00
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
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:
commit
f7ac7ee109
206 changed files with 18396 additions and 3497 deletions
|
|
@ -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.
|
||||
|
|
|
|||
81
CHANGELOG.md
81
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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 == '':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
605
backend/open_webui/models/analytics_sessions.py
Normal file
605
backend/open_webui/models/analytics_sessions.py
Normal 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)}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
69
backend/open_webui/utils/session_records.py
Normal file
69
backend/open_webui/utils/session_records.py
Normal 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
|
||||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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', {})
|
||||
|
|
|
|||
|
|
@ -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
12
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
15
src/app.css
15
src/app.css
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()}`,
|
||||
|
|
|
|||
201
src/lib/apis/analytics/sessions.ts
Normal file
201
src/lib/apis/analytics/sessions.ts
Normal 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
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -460,6 +460,8 @@
|
|||
enable = true;
|
||||
functionNameFilterList = '';
|
||||
accessGrants = [];
|
||||
headers = '';
|
||||
showAdvanced = false;
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
|
|
|
|||
|
|
@ -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
433
src/lib/components/admin/Analytics/SessionDetail.svelte
Normal file
433
src/lib/components/admin/Analytics/SessionDetail.svelte
Normal 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>
|
||||
437
src/lib/components/admin/Analytics/SessionExplorer.svelte
Normal file
437
src/lib/components/admin/Analytics/SessionExplorer.svelte
Normal 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>
|
||||
94
src/lib/components/admin/Analytics/SessionSignals.svelte
Normal file
94
src/lib/components/admin/Analytics/SessionSignals.svelte
Normal 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>
|
||||
172
src/lib/components/admin/Analytics/SessionUsageChart.svelte
Normal file
172
src/lib/components/admin/Analytics/SessionUsageChart.svelte
Normal 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}
|
||||
140
src/lib/components/admin/Analytics/SessionWaterfall.svelte
Normal file
140
src/lib/components/admin/Analytics/SessionWaterfall.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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="">
|
||||
|
|
|
|||
|
|
@ -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 === ''
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@
|
|||
: ''}"
|
||||
data-arrow-selected={index === selectedModelIdx}
|
||||
data-value={item.value}
|
||||
on:focus={() => {
|
||||
selectedModelIdx = index;
|
||||
}}
|
||||
on:click={() => {
|
||||
onClick();
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue